diff --git a/package-lock.json b/package-lock.json index 4db0355..ca5e779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,18 @@ "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", + "@fastify/view": "^11.1.1", "bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", + "ejs": "^5.0.2", "fastify": "^5.7.4", "nanoid": "^5.1.6" }, "devDependencies": { "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", + "@types/ejs": "^3.1.5", "@types/node": "^25.3.3", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -809,6 +812,26 @@ "glob": "^13.0.0" } }, + "node_modules/@fastify/view": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/view/-/view-11.1.1.tgz", + "integrity": "sha512-GiHqT3R2eKJgWmy0s45eELTC447a4+lTM2o+8fSWeKwBe9VToeePuHJcKtOEXPrKGSddGO0RsNayULiS3aeHeQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1198,6 +1221,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1657,6 +1687,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/package.json b/package.json index 60b4f00..e1f5fd1 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,18 @@ "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", + "@fastify/view": "^11.1.1", "bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", + "ejs": "^5.0.2", "fastify": "^5.7.4", "nanoid": "^5.1.6" }, "devDependencies": { "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", + "@types/ejs": "^3.1.5", "@types/node": "^25.3.3", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 6b16a52..518bfed 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -7,16 +7,17 @@ import type { Config } from '../config.ts'; import type { Logger } from '../middleware/logging.ts'; import type { JwtPayload } from '../types.ts'; import type { LockoutService } from '../services/lockout.ts'; -import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; +import { createFile, getFileById, getFilesByUserId, deleteFile, type FileRow } from '../db/files.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; import { attemptLogin } from '../services/login-handler.ts'; import { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts'; import { SESSION_COOKIE_NAME } from '../constants.ts'; -import { loginPage } from '../views/login.ts'; -import { uploadPage, uploadResultPage } from '../views/upload.ts'; -import { fileListPage } from '../views/file-list.ts'; -import { fileViewPage } from '../views/file-view.ts'; -import { notFoundPage } from '../views/not-found.ts'; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} interface Deps { db: Database.Database; @@ -65,7 +66,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps await request.jwtVerify(); return reply.redirect('/upload'); } catch { - return reply.type('text/html').send(loginPage()); + return reply.view('login.ejs', { title: 'Login', error: null }); } }); @@ -85,13 +86,12 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps if (result.kind === 'locked') { return reply - .type('text/html') .header('Retry-After', String(result.retryAfterSeconds)) - .send(loginPage({ error: 'Invalid username or password' })); + .view('login.ejs', { title: 'Login', error: 'Invalid username or password' }); } if (result.kind !== 'success') { - return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); + return reply.view('login.ejs', { title: 'Login', error: 'Invalid username or password' }); } issueSessionCookie( @@ -111,7 +111,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps // GET /upload app.get('/upload', { preHandler: requireAuth }, async (_request, reply) => { - reply.type('text/html').send(uploadPage()); + return reply.view('upload.ejs', { title: 'Upload', authed: true, error: null }); }); // POST /upload @@ -120,7 +120,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps const data = await request.file(); if (!data) { - return reply.type('text/html').send(uploadPage({ error: 'No file selected' })); + return reply.view('upload.ejs', { title: 'Upload', authed: true, error: 'No file selected' }); } const fileBuffer = await data.toBuffer(); @@ -141,14 +141,15 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }); const shareUrl = `${config.baseUrl}/f/${id}`; - reply.type('text/html').send(uploadResultPage(shareUrl, data.filename)); + return reply.view('upload-result.ejs', { title: 'File uploaded', authed: true, shareUrl, filename: data.filename }); }); // GET /files app.get('/files', { preHandler: requireAuth }, async (request, reply) => { const { sub: userId } = request.user as JwtPayload; const files = getFilesByUserId(db, userId); - reply.type('text/html').send(fileListPage(files, config.baseUrl)); + const filesWithSize = files.map((f: FileRow) => ({ ...f, sizeFormatted: formatBytes(f.size) })); + return reply.view('file-list.ejs', { title: 'My files', authed: true, files: filesWithSize, baseUrl: config.baseUrl }); }); // POST /files/:id/delete @@ -182,11 +183,17 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps if (!file) { await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id }); - return reply.status(404).type('text/html').send(notFoundPage()); + return reply.status(404).view('not-found.ejs', { title: 'Not found' }); } const isOwner = userId !== null && userId === file.user_id; - reply.type('text/html').send(fileViewPage(file, isOwner)); + return reply.view('file-view.ejs', { + title: file.original_name, + authed: isOwner, + hideHeader: !isOwner, + file, + isOwner, + }); }); // GET /f/:id/raw — serve raw file with range request support @@ -235,6 +242,6 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps // 404 handler app.setNotFoundHandler((_request, reply) => { - reply.status(404).type('text/html').send(notFoundPage()); + return reply.status(404).view('not-found.ejs', { title: 'Not found' }); }); }; diff --git a/src/server.ts b/src/server.ts index 67e2864..877e1f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,8 @@ import fastifyMultipart from '@fastify/multipart'; import fastifyFormbody from '@fastify/formbody'; import fastifyStatic from '@fastify/static'; import fastifyRateLimit from '@fastify/rate-limit'; +import fastifyView from '@fastify/view'; +import ejs from 'ejs'; import { join } from 'path'; import { fileURLToPath } from 'url'; import type Database from 'better-sqlite3'; @@ -39,6 +41,12 @@ export function createServer({ config, db }: ServerDeps) { root: join(__dirname, '..', 'public'), prefix: '/public/', }); + app.register(fastifyView, { + engine: { ejs }, + root: join(__dirname, '..', 'views'), + layout: '_layout.ejs', + defaultContext: { authed: false, hideHeader: false }, + }); app.register(fastifyRateLimit, { global: false, keyGenerator: (req) => req.ip, diff --git a/src/views/file-list.ts b/src/views/file-list.ts deleted file mode 100644 index 700d434..0000000 --- a/src/views/file-list.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { layout, escHtml } from './layout.ts'; -import type { FileRow } from '../db/files.ts'; - -export function fileListPage(files: FileRow[], baseUrl: string): string { - const rows = files.length === 0 - ? '
| Name | Type | Size | Uploaded | - |
|---|
${opts.error}
` - : ''; - - return layout('Login', ` -${opts.error}
` : ''; - - return layout('Upload', ` -| Name | Type | Size | Uploaded | + |
|---|---|---|---|---|
| No files yet. Upload one. | ||||
| <%= f.original_name %> | +<%= f.mime_type %> | +<%= f.sizeFormatted %> | +<%= f.created_at %> | ++ + + | +
<%= error %>
+ <% } %> + +<%= error %>
+ <% } %> + +