diff --git a/src/client/pages/view/[id].tsx b/src/client/pages/view/[id].tsx index 11c16846..11ae6389 100644 --- a/src/client/pages/view/[id].tsx +++ b/src/client/pages/view/[id].tsx @@ -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; host: string; - pw?: string | null; + token?: string | null; metrics?: Awaited>; filesRoute?: string; }; @@ -42,7 +42,7 @@ export default function ViewFileId() { const data = useSsrData(); 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(''); const [passwordError, setPasswordError] = useState(''); @@ -50,7 +50,7 @@ export default function ViewFileId() { useTitle(file.originalName ?? file.name ?? 'View File'); - return password && !pw ? ( + return password && !token ? ( {}} opened={true} withCloseButton={false} centered title='Password required'>
{ @@ -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' > @@ -143,7 +144,7 @@ export default function ViewFileId() {
- +
) : ( @@ -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' > @@ -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' > @@ -214,7 +215,7 @@ export default function ViewFileId() { - + {user?.view!.content && ( diff --git a/src/client/pages/view/url/[id].tsx b/src/client/pages/view/url/[id].tsx index 5ce84244..a4fd8ba4 100644 --- a/src/client/pages/view/url/[id].tsx +++ b/src/client/pages/view/url/[id].tsx @@ -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(''); const [passwordError, setPasswordError] = useState(''); @@ -18,7 +19,7 @@ export default function ViewUrlId() { if (!password && url.destination) window.location.href = url.destination; }, []); - return password ? ( + return password && !token ? ( {}} opened={true} withCloseButton={false} centered title='Password required'> { @@ -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'); } diff --git a/src/client/ssr-view-url/server.tsx b/src/client/ssr-view-url/server.tsx index 553e7739..13e21dd1 100644 --- a/src/client/ssr-view-url/server.tsx +++ b/src/client/ssr-view-url/server.tsx @@ -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 } }, diff --git a/src/client/ssr-view/server.tsx b/src/client/ssr-view/server.tsx index a2e8c20b..6745130b 100644 --- a/src/client/ssr-view/server.tsx +++ b/src/client/ssr-view/server.tsx @@ -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, diff --git a/src/components/file/DashboardFileType/index.tsx b/src/components/file/DashboardFileType/index.tsx index 61760b0b..10417db5 100644 --- a/src/components/file/DashboardFileType/index.tsx +++ b/src/components/file/DashboardFileType/index.tsx @@ -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() || ''; diff --git a/src/components/file/DashboardFileType/useFileUrls.ts b/src/components/file/DashboardFileType/useFileUrls.ts index 5722ac91..116c9af6 100644 --- a/src/components/file/DashboardFileType/useFileUrls.ts +++ b/src/components/file/DashboardFileType/useFileUrls.ts @@ -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]); } diff --git a/src/lib/accessToken.ts b/src/lib/accessToken.ts new file mode 100644 index 00000000..e8357e45 --- /dev/null +++ b/src/lib/accessToken.ts @@ -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; + 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; + } +} diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index 9f1628eb..e7445b9c 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -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', diff --git a/src/lib/passwordCookie.ts b/src/lib/passwordCookie.ts deleted file mode 100644 index 24a84729..00000000 --- a/src/lib/passwordCookie.ts +++ /dev/null @@ -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, - 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; - } -} diff --git a/src/server/routes/api/user/files/[id]/password.ts b/src/server/routes/api/user/files/[id]/password.ts index 2cbb5f29..ecdd7df9 100644 --- a/src/server/routes/api/user/files/[id]/password.ts +++ b/src/server/routes/api/user/files/[id]/password.ts @@ -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 }); }, ); }, diff --git a/src/server/routes/api/user/files/[id]/raw.ts b/src/server/routes/api/user/files/[id]/raw.ts index 31e36567..c6fcdf40 100644 --- a/src/server/routes/api/user/files/[id]/raw.ts +++ b/src/server/routes/api/user/files/[id]/raw.ts @@ -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)); diff --git a/src/server/routes/api/user/urls/[id]/password.ts b/src/server/routes/api/user/urls/[id]/password.ts index e12317c0..e9645234 100644 --- a/src/server/routes/api/user/urls/[id]/password.ts +++ b/src/server/routes/api/user/urls/[id]/password.ts @@ -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 }); }, ); }, diff --git a/src/server/routes/files.dy.ts b/src/server/routes/files.dy.ts index ae619e6e..ad302c3d 100644 --- a/src/server/routes/files.dy.ts +++ b/src/server/routes/files.dy.ts @@ -7,7 +7,7 @@ type Params = { }; type Query = { - pw?: string; + token?: string; download?: string; }; diff --git a/src/server/routes/raw/[id].ts b/src/server/routes/raw/[id].ts index 9b790002..5efdaebe 100644 --- a/src/server/routes/raw/[id].ts +++ b/src/server/routes/raw/[id].ts @@ -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)); diff --git a/src/server/routes/urls.dy.ts b/src/server/routes/urls.dy.ts index 6730d405..617d2b74 100644 --- a/src/server/routes/urls.dy.ts +++ b/src/server/routes/urls.dy.ts @@ -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({