refactor: use access tokens for file/url passwords

no longer using cookies, since they are buggy and weird with caching
using a access token that "expires" in 10 minutes
This commit is contained in:
diced
2026-05-04 18:21:53 -07:00
parent 15f5279ddb
commit a99b0f4f1d
15 changed files with 122 additions and 116 deletions
+11 -10
View File
@@ -1,5 +1,6 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
@@ -24,7 +25,6 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
type SsrData = {
@@ -33,7 +33,7 @@ type SsrData = {
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
token?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
@@ -42,7 +42,7 @@ export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
const { file, password, code, user, host, metrics, filesRoute, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -50,7 +50,7 @@ export default function ViewFileId() {
useTitle(file.originalName ?? file.name ?? 'View File');
return password && !pw ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -63,7 +63,8 @@ export default function ViewFileId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/${file.name}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
@@ -104,7 +105,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -143,7 +144,7 @@ export default function ViewFileId() {
</Collapse>
<Center m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} fullscreen />
<DashboardFileType file={file as unknown as File} token={token} show code={code} fullscreen />
</Center>
</>
) : (
@@ -194,7 +195,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}${token ? `?token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
@@ -205,7 +206,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -214,7 +215,7 @@ export default function ViewFileId() {
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
{user?.view!.content && (
<Typography>
+5 -3
View File
@@ -6,10 +6,11 @@ export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
token?: string | null;
}>();
if (!data) return null;
const { url, password } = data;
const { url, password, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -18,7 +19,7 @@ export default function ViewUrlId() {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -31,7 +32,8 @@ export default function ViewUrlId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
+11 -19
View File
@@ -1,11 +1,9 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { getPasswordCookie } from '@/lib/passwordCookie';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { createRoutes } from './routes';
@@ -17,13 +15,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -52,31 +48,27 @@ export async function render(
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = getPasswordCookie(cookies, 'url', urlEntry.id);
const token = req.query.token;
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
token: valid ? token : null,
};
delete (data.url as any).password;
const routes = createRoutes(themes, defaultTheme);
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
if (!valid) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
+10 -14
View File
@@ -5,21 +5,19 @@ import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { verifyAccessToken } from '@/lib/accessToken';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { getPasswordCookie } from '@/lib/passwordCookie';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import { stripHtml } from '@/lib/stripHtml';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
@@ -45,11 +43,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -95,17 +93,15 @@ export async function render(
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = getPasswordCookie(cookies, 'file', file.id);
const token = req.query.token;
const valid = token && file.password ? verifyAccessToken(token, 'file', file.id) : false;
const hasPassword = !!file.password;
delete (file as any).password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
console.log('File is password protected');
if (!valid) {
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
@@ -142,7 +138,7 @@ export async function render(
const data = {
file,
password: hasPassword,
pw: pw || null,
token: valid ? token : null,
code,
user,
host,
@@ -39,7 +39,7 @@ export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon
export default function DashboardFileType({
file,
show,
password,
token,
code,
allowZoom,
fullscreen,
@@ -47,14 +47,14 @@ export default function DashboardFileType({
}: {
file: DbFile | File;
show?: boolean;
password?: string | null;
token?: string | null;
code?: boolean;
allowZoom?: boolean;
fullscreen?: boolean;
scrollParent?: HTMLElement | null;
}) {
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, password });
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
const db = isDbFile(file) ? file : null;
const extension = file.name.split('.').pop() || '';
@@ -2,15 +2,17 @@ import { useUserStore } from '@/lib/client/store/user';
import type { File as DbFile } from '@/lib/db/models/file';
import { useMemo } from 'react';
export function appendPassword(url: string, password?: string | null) {
return `${url}${password ? `?pw=${encodeURIComponent(password)}` : ''}`;
function appendToken(url: string, token?: string | null) {
if (!token) return url;
return `${url}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
export function isDbFile(file: DbFile | File): file is DbFile {
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
}
export default function useFileUrls({ file, password }: { file: DbFile | File; password?: string | null }): {
export default function useFileUrls({ file, token }: { file: DbFile | File; token?: string | null }): {
fileUrl: string;
thumbnailUrl: string | null;
viewUrl: string | null;
@@ -20,17 +22,15 @@ export default function useFileUrls({ file, password }: { file: DbFile | File; p
const blobUrl = useMemo(() => (isDbFile(file) ? null : URL.createObjectURL(file as File)), [file]);
return useMemo(() => {
if (!isDbFile(file)) {
return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
}
if (!isDbFile(file)) return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
const thumb = file.thumbnail?.path;
const thumbnailUrl = thumb ? (user ? `/api/user/files/${thumb}/raw` : `/raw/${thumb}`) : null;
return {
fileUrl: appendPassword(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, password),
viewUrl: appendPassword(`/view/${file.name}`, password),
fileUrl: appendToken(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, token),
viewUrl: appendToken(`/view/${file.name}`, token),
thumbnailUrl,
};
}, [blobUrl, file, password, user]);
}, [token, blobUrl, file, user]);
}
+37
View File
@@ -0,0 +1,37 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
type AccessTokenPayload = {
type: string;
id: string;
expiry: number;
};
export function createAccessToken({ type, id }: { type: string; id: string }): string {
const payload: AccessTokenPayload = {
type: type,
id,
expiry: Date.now() + 5 * 60_000, // 5 minutes
};
return encrypt(JSON.stringify(payload), config.core.secret);
}
export function verifyAccessToken(token: string | null | undefined, type: string, id: string): boolean {
if (!token) return false;
try {
const raw = decrypt(token, config.core.secret);
const payload = JSON.parse(raw) as Partial<AccessTokenPayload>;
if (!payload || typeof payload !== 'object') return false;
if (payload.type !== type) return false;
if (payload.id !== id) return false;
if (typeof payload.expiry !== 'number') return false;
if (payload.expiry < Date.now()) return false;
return true;
} catch {
return false;
}
}
+1
View File
@@ -90,6 +90,7 @@ export const API_ERRORS = {
3015: 'Not super admin',
3016: 'OAuth registration is disabled',
3017: 'OAuth login is not allowed for this account',
3018: 'Invalid access token provided.',
// 4xxx, not founds
4000: 'File not found',
-27
View File
@@ -1,27 +0,0 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
import { FastifyReply } from 'fastify';
export function setPasswordCookie(res: FastifyReply, kind: 'file' | 'url', id: string, password: string) {
res.cookie(`${kind}_pw_${id}`, encrypt(password, config.core.secret), {
sameSite: 'lax',
expires: new Date(Date.now() + 15 * 60_000),
httpOnly: true,
secure: config.core.returnHttpsUrls,
path: '/',
});
}
export function getPasswordCookie(
cookies: Record<string, string | undefined>,
kind: 'file' | 'url',
id: string,
) {
const cookie = cookies[`${kind}_pw_${id}`];
if (!cookie) return null;
try {
return decrypt(cookie, config.core.secret);
} catch {
return null;
}
}
@@ -1,10 +1,10 @@
import { ApiError } from '@/lib/api/errors';
import { createAccessToken } from '@/lib/accessToken';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits';
import { zStringTrimmed } from '@/lib/validation';
import { setPasswordCookie } from '@/lib/passwordCookie';
import typedPlugin from '@/server/typedPlugin';
import z from 'zod';
@@ -21,7 +21,8 @@ export default typedPlugin(
PATH,
{
schema: {
description: 'Verify the password for a password-protected file by ID or name.',
description:
'Verify the password for a password-protected file by ID or name and receive an access token if the password is correct',
body: z.object({
password: zStringTrimmed,
}),
@@ -31,6 +32,7 @@ export default typedPlugin(
response: {
200: z.object({
success: z.boolean(),
token: z.string(),
}),
},
},
@@ -60,11 +62,12 @@ export default typedPlugin(
throw new ApiError(3005);
}
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
logger.info(`${file.name} was accessed with the correct password, a new access token was created`, {
ua: req.headers['user-agent'],
});
setPasswordCookie(res, 'file', file.id, req.body.password);
return res.send({ success: true });
const token = createAccessToken({ type: 'file', id: file.id });
return res.send({ success: true, token });
},
);
},
+5 -6
View File
@@ -1,7 +1,7 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { sanitizeFilename } from '@/lib/fs';
@@ -27,7 +27,7 @@ export default typedPlugin(
id: z.string(),
}),
querystring: z.object({
pw: z.string().optional(),
token: z.string().optional(),
download: zQsBoolean.optional(),
}),
tags: ['auth'],
@@ -35,7 +35,7 @@ export default typedPlugin(
preHandler: [userMiddleware],
},
async (req, res) => {
const { pw, download } = req.query;
const { token, download } = req.query;
const id = sanitizeFilename(req.params.id);
if (!id) throw new ApiError(9002);
@@ -114,9 +114,8 @@ export default typedPlugin(
}
if (file?.password) {
if (!pw) throw new ApiError(3004);
const verified = await verifyPassword(pw, file.password!);
if (!verified) throw new ApiError(3005);
const valid = verifyAccessToken(token, 'file', file.id);
if (!valid) throw new ApiError(3018);
}
const size = file?.size || (await datasource.size(file?.name ?? id));
@@ -1,15 +1,16 @@
import { ApiError } from '@/lib/api/errors';
import { createAccessToken } from '@/lib/accessToken';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits';
import { zStringTrimmed } from '@/lib/validation';
import { setPasswordCookie } from '@/lib/passwordCookie';
import typedPlugin from '@/server/typedPlugin';
import z from 'zod';
export type ApiUserUrlsIdPasswordResponse = {
success: boolean;
token: string;
};
const logger = log('api').c('user').c('urls').c('[id]').c('password');
@@ -28,6 +29,12 @@ export default typedPlugin(
body: z.object({
password: zStringTrimmed,
}),
response: {
200: z.object({
success: z.boolean(),
token: z.string(),
}),
},
},
...secondlyRatelimit(2),
},
@@ -59,9 +66,8 @@ export default typedPlugin(
ua: req.headers['user-agent'],
});
setPasswordCookie(res, 'url', url.id, req.body.password);
return res.send({ success: true });
const token = createAccessToken({ type: 'url', id: url.id });
return res.send({ success: true, token });
},
);
},
+1 -1
View File
@@ -7,7 +7,7 @@ type Params = {
};
type Query = {
pw?: string;
token?: string;
download?: string;
};
+5 -7
View File
@@ -1,7 +1,7 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
@@ -18,7 +18,7 @@ type Params = {
};
type Querystring = {
pw?: string;
token?: string;
download?: string;
};
@@ -32,7 +32,7 @@ export const rawFileHandler = async (
res: FastifyReply,
) => {
const { id } = req.params;
const { pw, download } = req.query;
const { token, download } = req.query;
if (id.startsWith('.thumbnail')) {
const thumbnail = await prisma.thumbnail.findFirst({
@@ -80,10 +80,8 @@ export const rawFileHandler = async (
}
if (file?.password) {
if (!pw) throw new ApiError(3004);
const verified = await verifyPassword(pw, file.password!);
if (!verified) throw new ApiError(3005);
const valid = verifyAccessToken(token, 'file', file.id);
if (!valid) throw new ApiError(3018);
}
const size = file?.size || (await datasource.size(file?.name ?? id));
+5 -7
View File
@@ -1,5 +1,5 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { FastifyReply, FastifyRequest } from 'fastify';
@@ -9,7 +9,7 @@ type Params = {
};
type Query = {
pw?: string;
token?: string;
};
const logger = log('server').c('urls');
@@ -19,7 +19,7 @@ export async function urlsRoute(
res: FastifyReply,
) {
const { id } = req.params;
const { pw } = req.query;
const { token } = req.query;
const url = await prisma.url.findFirst({
where: {
@@ -48,10 +48,8 @@ export async function urlsRoute(
}
if (url.password) {
if (!pw) return res.redirect(`/view/url/${url.id}`);
const verified = await verifyPassword(pw as string, url.password);
if (!verified) return res.redirect(`/view/url/${url.id}`);
const valid = verifyAccessToken(token, 'url', url.id);
if (!valid) return res.redirect(`/view/url/${url.id}`);
}
await prisma.url.update({