Skip to content

Commit 3afd4f8

Browse files
committed
fix: update changelog for v1.0.4 release, enhance public file viewing logic, and normalize file payload typing (an attempt to resolve the issue in issue#1), enforce anon from file settings, and clear lint warnings
1 parent 3aaa368 commit 3afd4f8

23 files changed

Lines changed: 324 additions & 242 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ docs/
5959
coverage/
6060
x_cookies.txt
6161
*.pem
62+
/issues

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ This project follows [Semantic Versioning](https://iconical.dev/versioning).
1111

1212
---
1313

14+
## v1.0.4 – Public View Route Fixes 🧩
15+
16+
**Released: February 22, 2026**
17+
18+
This patch fixes a regression where public files failed to open from the `/v/:slug` view route in some Docker/reverse-proxy setups.
19+
20+
### 🔎 Root Cause
21+
22+
- In `v1.0.3`, the `/v/:slug` page resolved file data via an internal server-side HTTP self-call to `/api/v1/files/:slug`.
23+
- In Docker/port-mapped deployments (for example, opening the app at `localhost:3419`), that host/port can be valid for the browser but not reachable from inside the container runtime.
24+
- When that self-call failed, `/v/:slug` incorrectly fell back to a missing/private state even for public files.
25+
- `/x/:slug` still worked because it does not rely on that same internal HTTP roundtrip path.
26+
27+
### 🐛 Fixes
28+
29+
- Fixed public file viewing through `/v/:slug` by removing the fragile internal HTTP self-call and resolving file access in-process.
30+
- Kept `/x/:slug` and `/v/:slug` behavior consistent for public file access checks.
31+
- Normalized file payload typing for `createdAt` in the `/v` page flow to prevent runtime-shape/TypeScript mismatch.
32+
- Fixed current blocking lint errors in dialog open-state reset flow and intersection observer fallback handling.
33+
- Hardened anonymous file behavior so `/v` anonymity is enforced by stored file settings/server logic instead of `?anon=1` URL parameters.
34+
- Added an `Anonymous share` toggle to file edit details and removed the legacy `Copy Anonymous URL` action.
35+
- Fixed an intermittent `/v/:slug` image preview state where media could remain blurred after load due to missed cached-load events.
36+
- Fixed the RemoteUploadDialog URLs input keeps overflowing when the URL is long.
37+
38+
---
39+
1440
## v1.0.3 – CORS and Security Enhancements 🔐
1541

1642
**Released: February 8, 2026**

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "swush",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"private": true,
55
"description": "Swush; A secure, self-hosted file sharing app with privacy-first features.",
66
"author": {

src/app/(files)/v/[slug]/page.tsx

Lines changed: 65 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -19,55 +19,78 @@ import type { Metadata, Viewport } from "next";
1919
import { getDefaultMetadata } from "@/lib/head";
2020
import { notFound } from "next/navigation";
2121
import { headers, cookies } from "next/headers";
22+
import { NextRequest } from "next/server";
2223
import FileUnlockAndView, {
2324
FileDto,
2425
} from "@/components/Files/FileUnlockAndView";
2526
import ExternalLayout from "@/components/Common/ExternalLayout";
2627
import { getPublicRuntimeSettings } from "@/lib/server/runtime-settings";
2728
import { formatBytes, isSpoilerLabel } from "@/lib/helpers";
28-
import { apiV1Absolute } from "@/lib/api-path";
2929
import {
3030
applyEmbedTemplates,
3131
applyEmbedSettings,
3232
getEmbedSettingsByUserId,
3333
resolveEmbedThemeColor,
3434
resolveEmbedViewport,
3535
} from "@/lib/server/embed-settings";
36+
import { getFile as readFileBySlug } from "@/lib/api/files/read";
3637

3738
export const dynamic = "force-dynamic";
3839

3940
type Params = Promise<{ slug: string }>;
40-
type SearchParams = Promise<{ anon?: string }>;
41+
42+
type FileDtoLike = Omit<FileDto, "createdAt"> & {
43+
createdAt: string | Date | null;
44+
};
45+
46+
async function buildFileReadRequest(slug: string) {
47+
const h = await headers();
48+
const host = h.get("x-forwarded-host") ?? h.get("host");
49+
const proto = h.get("x-forwarded-proto") ?? "http";
50+
const cookieHeader = (await cookies()).toString();
51+
52+
const base = `${proto}://${host ?? "localhost"}`;
53+
const url = new URL(`/api/v1/files/${encodeURIComponent(slug)}`, base);
54+
url.searchParams.set("include", "owner");
55+
56+
const requestHeaders = new Headers();
57+
if (cookieHeader) requestHeaders.set("cookie", cookieHeader);
58+
59+
return new NextRequest(url, {
60+
headers: requestHeaders,
61+
});
62+
}
63+
64+
function normalizeFileDto(file: FileDtoLike): FileDto {
65+
return {
66+
...file,
67+
createdAt:
68+
file.createdAt instanceof Date
69+
? file.createdAt.toISOString()
70+
: (file.createdAt ?? ""),
71+
};
72+
}
73+
74+
async function readFileDto(slug: string) {
75+
const req = await buildFileReadRequest(slug);
76+
const result = await readFileBySlug(req, slug);
77+
if (result.status !== 200) return null;
78+
return normalizeFileDto(result.body as FileDtoLike);
79+
}
4180

4281
export async function generateMetadata({
4382
params,
44-
searchParams,
4583
}: {
4684
params: Params;
47-
searchParams: SearchParams;
85+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
4886
}): Promise<Metadata> {
4987
const { slug } = await params;
50-
const { anon } = await searchParams;
51-
const isAnonymous = ["1", "true", "yes"].includes((anon ?? "").toLowerCase());
5288
const { appName, appUrl } = await getPublicRuntimeSettings();
5389
const defaultMetadata = await getDefaultMetadata();
5490

5591
let file: FileDto | null = null;
5692
try {
57-
const query = new URLSearchParams();
58-
if (!isAnonymous) query.set("include", "owner");
59-
if (isAnonymous) query.set("anon", "1");
60-
const res = await fetch(
61-
apiV1Absolute(appUrl, `/files/${slug}?${query.toString()}`),
62-
{
63-
cache: "no-store",
64-
headers: {
65-
"x-no-audit": "1",
66-
"x-audit-source": "metadata",
67-
},
68-
},
69-
);
70-
if (res.ok) file = (await res.json()) as FileDto;
93+
file = await readFileDto(slug);
7194
} catch {}
7295

7396
const sizeText = ` I just wasted ${formatBytes(
@@ -89,7 +112,7 @@ export async function generateMetadata({
89112
ogImage = `${appUrl}/x/${slug}.png`;
90113
}
91114

92-
const anonymousActive = isAnonymous || Boolean(file?.anonymousShareEnabled);
115+
const anonymousActive = Boolean(file?.anonymousShareEnabled);
93116
const ownerUsername = anonymousActive ? undefined : file?.ownerUsername;
94117
const ownerDisplay = anonymousActive
95118
? undefined
@@ -175,36 +198,18 @@ export async function generateMetadata({
175198

176199
export async function generateViewport({
177200
params,
178-
searchParams,
179201
}: {
180202
params: Params;
181-
searchParams: SearchParams;
203+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
182204
}): Promise<Viewport> {
183205
const { slug } = await params;
184-
const { anon } = await searchParams;
185-
186-
const isAnonymous = ["1", "true", "yes"].includes((anon ?? "").toLowerCase());
187-
const { appUrl } = await getPublicRuntimeSettings();
188206

189207
let file: FileDto | null = null;
190208
try {
191-
const query = new URLSearchParams();
192-
if (!isAnonymous) query.set("include", "owner");
193-
if (isAnonymous) query.set("anon", "1");
194-
const res = await fetch(
195-
apiV1Absolute(appUrl, `/files/${slug}?${query.toString()}`),
196-
{
197-
cache: "no-store",
198-
headers: {
199-
"x-no-audit": "1",
200-
"x-audit-source": "metadata",
201-
},
202-
},
203-
);
204-
if (res.ok) file = (await res.json()) as FileDto;
209+
file = await readFileDto(slug);
205210
} catch {}
206211

207-
const anonymousActive = isAnonymous || Boolean(file?.anonymousShareEnabled);
212+
const anonymousActive = Boolean(file?.anonymousShareEnabled);
208213
return resolveEmbedViewport(anonymousActive ? undefined : file?.userId);
209214
}
210215

@@ -214,59 +219,37 @@ type FileFetch = {
214219
error?: string | null;
215220
};
216221

217-
async function getFile(slug: string, anonymous: boolean): Promise<FileFetch> {
222+
async function getFile(slug: string): Promise<FileFetch> {
218223
try {
219-
const h = await headers();
220-
const host = h.get("x-forwarded-host") ?? h.get("host");
221-
const proto = h.get("x-forwarded-proto") ?? "http";
222-
if (!host) return { data: null, status: 500 };
223-
224-
const base = `${proto}://${host}`;
225-
const cookieHeader = (await cookies()).toString();
226-
const query = new URLSearchParams();
227-
if (!anonymous) query.set("include", "owner");
228-
if (anonymous) query.set("anon", "1");
229-
const res = await fetch(
230-
apiV1Absolute(base, `/files/${slug}?${query.toString()}`),
231-
{
232-
cache: "no-store",
233-
headers: { cookie: cookieHeader },
234-
},
235-
);
236-
237-
if (!res.ok) {
238-
let error: string | null = null;
239-
try {
240-
const body = (await res.json()) as { message?: string } | null;
241-
error = body?.message ?? null;
242-
} catch {}
243-
return { data: null, status: res.status, error };
224+
const req = await buildFileReadRequest(slug);
225+
const result = await readFileBySlug(req, slug);
226+
if (result.status !== 200) {
227+
const body = result.body as { message?: string } | undefined;
228+
return {
229+
data: null,
230+
status: result.status,
231+
error: body?.message ?? null,
232+
};
244233
}
245234

246-
const data = (await res.json()) as FileDto;
247-
return { data, status: 200, error: null };
235+
return {
236+
data: normalizeFileDto(result.body as FileDtoLike),
237+
status: 200,
238+
error: null,
239+
};
248240
} catch {
249241
return { data: null, status: 500, error: null };
250242
}
251243
}
252244

253245
export default async function ViewFilePage({
254246
params,
255-
searchParams,
256247
}: {
257248
params: Promise<{ slug: string }>;
258-
searchParams?: Promise<{ anon?: string }>;
249+
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
259250
}) {
260251
const resolvedParams = await params;
261-
const resolvedSearchParams = await searchParams;
262-
const isAnonymous = ["1", "true", "yes"].includes(
263-
(resolvedSearchParams?.anon ?? "").toLowerCase(),
264-
);
265-
const {
266-
data: file,
267-
status,
268-
error,
269-
} = await getFile(resolvedParams.slug, isAnonymous);
252+
const { data: file, status, error } = await getFile(resolvedParams.slug);
270253

271254
if (status === 404) {
272255
return notFound();
@@ -286,7 +269,7 @@ export default async function ViewFilePage({
286269

287270
if (!file) notFound();
288271

289-
const anonymousActive = isAnonymous || Boolean(file?.anonymousShareEnabled);
272+
const anonymousActive = Boolean(file?.anonymousShareEnabled);
290273
const embedAccent = resolveEmbedThemeColor(
291274
anonymousActive ? null : await getEmbedSettingsByUserId(file?.userId),
292275
);

src/app/api/v1/profile/api-keys/[id]/secret/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ export const runtime = "nodejs";
2727

2828
type Params = Promise<{ id: string }>;
2929

30-
export const GET = withApiError(async function GET(req: NextRequest, { params }: { params: Params }) {
30+
export const GET = withApiError(async function GET(
31+
req: NextRequest,
32+
{ params }: { params: Params },
33+
) {
3134
const session = await auth.api.getSession({ headers: req.headers });
3235
if (!session?.user?.id) {
3336
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -37,7 +40,12 @@ export const GET = withApiError(async function GET(req: NextRequest, { params }:
3740
const [row] = await db
3841
.select()
3942
.from(apiKeySecrets)
40-
.where(and(eq(apiKeySecrets.keyId, id), eq(apiKeySecrets.userId, session.user.id)))
43+
.where(
44+
and(
45+
eq(apiKeySecrets.keyId, id),
46+
eq(apiKeySecrets.userId, session.user.id),
47+
),
48+
)
4149
.limit(1);
4250

4351
if (!row) {
@@ -51,10 +59,10 @@ export const GET = withApiError(async function GET(req: NextRequest, { params }:
5159
tag: row.tag,
5260
});
5361
return NextResponse.json({ key });
54-
} catch (err) {
62+
} catch {
5563
return NextResponse.json(
5664
{ error: "Failed to decrypt API key" },
57-
{ status: 500 }
65+
{ status: 500 },
5866
);
5967
}
6068
});

0 commit comments

Comments
 (0)