Update button styling and render additional elements on file page if authenticated

This commit is contained in:
2026-03-03 16:35:02 -08:00
parent c017761bd1
commit 191f5298d1
5 changed files with 87 additions and 20 deletions

View File

@@ -170,8 +170,7 @@ input[type="file"] {
input[type="file"]::-webkit-file-upload-button { input[type="file"]::-webkit-file-upload-button {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
letter-spacing: 0.06em; letter-spacing: 0.02em;
text-transform: uppercase;
background: var(--black); background: var(--black);
color: var(--white); color: var(--white);
border: none; border: none;
@@ -182,8 +181,7 @@ input[type="file"]::-webkit-file-upload-button {
input[type="file"]::file-selector-button { input[type="file"]::file-selector-button {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
letter-spacing: 0.06em; letter-spacing: 0.02em;
text-transform: uppercase;
background: var(--black); background: var(--black);
color: var(--white); color: var(--white);
border: none; border: none;
@@ -200,8 +198,7 @@ button[type="submit"],
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.02em;
text-transform: uppercase;
background: var(--black); background: var(--black);
color: var(--white); color: var(--white);
border: var(--border); border: var(--border);
@@ -237,8 +234,7 @@ a.btn {
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.02em;
text-transform: uppercase;
text-decoration: none; text-decoration: none;
background: var(--black); background: var(--black);
color: var(--white); color: var(--white);
@@ -285,8 +281,7 @@ a.btn:hover {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.02em;
text-transform: uppercase;
background: var(--white); background: var(--white);
color: var(--black); color: var(--black);
border: var(--border); border: var(--border);

View File

@@ -119,9 +119,16 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
reply.redirect('/files'); reply.redirect('/files');
}); });
// GET /f/:id — public file view // GET /f/:id — public file view (owner-aware)
app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => { app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => {
const { id } = request.params; const { id } = request.params;
let userId: number | null = null;
try {
await request.jwtVerify();
userId = (request.user as JwtPayload).sub;
} catch { /* not logged in — fine */ }
const file = getFileById(db, id); const file = getFileById(db, id);
if (!file) { if (!file) {
@@ -129,7 +136,8 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
return reply.status(404).type('text/html').send(notFoundPage()); return reply.status(404).type('text/html').send(notFoundPage());
} }
reply.type('text/html').send(fileViewPage(file)); const isOwner = userId !== null && userId === file.user_id;
reply.type('text/html').send(fileViewPage(file, isOwner));
}); });
// GET /f/:id/raw — serve raw file // GET /f/:id/raw — serve raw file

View File

@@ -1,7 +1,7 @@
import { layout, escHtml } from './layout.ts'; import { layout, escHtml } from './layout.ts';
import type { FileRow } from '../db/files.ts'; import type { FileRow } from '../db/files.ts';
export function fileViewPage(file: FileRow): string { export function fileViewPage(file: FileRow, isOwner: boolean): string {
const rawUrl = escHtml(`/f/${file.id}/raw`); const rawUrl = escHtml(`/f/${file.id}/raw`);
const safeName = escHtml(file.original_name); const safeName = escHtml(file.original_name);
const actions = ` const actions = `
@@ -10,6 +10,12 @@ export function fileViewPage(file: FileRow): string {
<a href="${rawUrl}" target="_blank" class="btn">Open</a> <a href="${rawUrl}" target="_blank" class="btn">Open</a>
</div>`; </div>`;
const deleteForm = isOwner
? `<form method="POST" action="/files/${escHtml(file.id)}/delete">
<button type="submit" class="danger">Delete</button>
</form>`
: '';
let media = ''; let media = '';
if (file.mime_type.startsWith('image/')) { if (file.mime_type.startsWith('image/')) {
media = `<img src="${rawUrl}" alt="${safeName}">`; media = `<img src="${rawUrl}" alt="${safeName}">`;
@@ -19,11 +25,14 @@ export function fileViewPage(file: FileRow): string {
media = `<audio controls src="${rawUrl}" preload="metadata"></audio>`; media = `<audio controls src="${rawUrl}" preload="metadata"></audio>`;
} }
const layoutOpts = isOwner ? { authed: true } : { hideHeader: true };
return layout(file.original_name, ` return layout(file.original_name, `
<div class="file-view"> <div class="file-view">
<h1>${safeName}</h1> <h1>${safeName}</h1>
${media} ${media}
${actions} ${actions}
${deleteForm}
</div> </div>
`, { hideLogo: true }); `, layoutOpts);
} }

View File

@@ -1,5 +1,5 @@
export function layout(title: string, body: string, opts: { authed?: boolean; hideLogo?: boolean } = {}): string { export function layout(title: string, body: string, opts: { authed?: boolean; hideHeader?: boolean } = {}): string {
const { authed = false, hideLogo = false } = opts; const { authed = false, hideHeader = false } = opts;
const nav = authed const nav = authed
? `<nav> ? `<nav>
<a href="/upload">Upload</a> <a href="/upload">Upload</a>
@@ -10,6 +10,13 @@ export function layout(title: string, body: string, opts: { authed?: boolean; hi
</nav>` </nav>`
: ''; : '';
const header = hideHeader
? ''
: ` <header>
<a href="/" class="logo">Nanodrop</a>
${nav}
</header>`;
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -19,10 +26,7 @@ export function layout(title: string, body: string, opts: { authed?: boolean; hi
<link rel="stylesheet" href="/public/style.css"> <link rel="stylesheet" href="/public/style.css">
</head> </head>
<body> <body>
<header> ${header}
${hideLogo ? '' : '<a href="/" class="logo">Nanodrop</a>'}
${nav}
</header>
<main> <main>
${body} ${body}
</main> </main>

View File

@@ -181,6 +181,57 @@ describe('GET /files — copy link', () => {
}); });
}); });
describe('GET /f/:id — owner-aware header', () => {
let ctx: TestContext;
let aliceToken: string;
let bobToken: string;
let fileId: string;
beforeEach(async () => {
ctx = createTestApp();
const hash = await hashPassword('secret');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
createUser(ctx.db, { username: 'bob', passwordHash: hash });
aliceToken = await loginAs(ctx.app, 'alice', 'secret');
bobToken = await loginAs(ctx.app, 'bob', 'secret');
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token: aliceToken },
...buildMultipart({ file: { filename: 'owned.txt', contentType: 'text/plain', data: Buffer.from('data') } }),
});
const match = uploadRes.body.match(/\/f\/([^/"]+)/);
fileId = match?.[1] ?? '';
});
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows nav when owner views their file', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('My Files');
});
it('shows delete button when owner views', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('delete');
});
it('no header when non-owner views', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: bobToken } });
expect(res.statusCode).toBe(200);
expect(res.body).not.toContain('<header');
});
it('no header when unauthenticated', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}` });
expect(res.statusCode).toBe(200);
expect(res.body).not.toContain('<header');
});
});
describe('POST /files/:id/delete', () => { describe('POST /files/:id/delete', () => {
let ctx: TestContext; let ctx: TestContext;
let token: string; let token: string;