Update button styling and render additional elements on file page if authenticated
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user