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 - ? 'No files yet. Upload one.' - : files.map((f) => { - const shareUrl = `${baseUrl}/f/${escHtml(f.id)}`; - return ` - - ${escHtml(f.original_name)} - ${escHtml(f.mime_type)} - ${formatBytes(f.size)} - ${escHtml(f.created_at)} - - -
- -
- - `; - }).join(''); - - return layout('My files', ` -

My files

-

Upload new file

-
- - - - - - - ${rows} -
NameTypeSizeUploaded
-
- `, { authed: true }); -} - -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`; -} diff --git a/src/views/file-view.ts b/src/views/file-view.ts deleted file mode 100644 index 628ce86..0000000 --- a/src/views/file-view.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { layout, escHtml } from './layout.ts'; -import type { FileRow } from '../db/files.ts'; - -export function fileViewPage(file: FileRow, isOwner: boolean): string { - const rawUrl = escHtml(`/f/${file.id}/raw`); - const safeName = escHtml(file.original_name); - const actions = ` -
- Download - Open -
`; - - const deleteForm = isOwner - ? `
- -
` - : ''; - - let media = ''; - if (file.mime_type.startsWith('image/')) { - media = `${safeName}`; - } else if (file.mime_type.startsWith('video/')) { - media = ``; - } else if (file.mime_type.startsWith('audio/')) { - media = ``; - } - - const layoutOpts = isOwner ? { authed: true } : { hideHeader: true }; - - return layout(file.original_name, ` -
-

${safeName}

- ${media} - ${actions} - ${deleteForm} -
- `, layoutOpts); -} diff --git a/src/views/layout.ts b/src/views/layout.ts deleted file mode 100644 index 63680f7..0000000 --- a/src/views/layout.ts +++ /dev/null @@ -1,45 +0,0 @@ -export function layout(title: string, body: string, opts: { authed?: boolean; hideHeader?: boolean } = {}): string { - const { authed = false, hideHeader = false } = opts; - const nav = authed - ? `` - : ''; - - const header = hideHeader - ? '' - : `
- - ${nav} -
`; - - return ` - - - - - - ${escHtml(title)} — Nanodrop - - - -${header} -
- ${body} -
- -`; -} - -export function escHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} diff --git a/src/views/login.ts b/src/views/login.ts deleted file mode 100644 index 80c9caf..0000000 --- a/src/views/login.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { layout } from './layout.ts'; - -export function loginPage(opts: { error?: string } = {}): string { - const errorHtml = opts.error - ? `

${opts.error}

` - : ''; - - return layout('Login', ` -
-

Sign in

- ${errorHtml} -
- - - -
-
- `); -} diff --git a/src/views/not-found.ts b/src/views/not-found.ts deleted file mode 100644 index f50e107..0000000 --- a/src/views/not-found.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { layout } from './layout.ts'; - -export function notFoundPage(): string { - return layout('Not found', ` -
-

404 — Not found

-

The page or file you requested does not exist.

-

Go home

-
- `); -} diff --git a/src/views/upload.ts b/src/views/upload.ts deleted file mode 100644 index 143ba8b..0000000 --- a/src/views/upload.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { layout, escHtml } from './layout.ts'; - -export function uploadPage(opts: { error?: string } = {}): string { - const errorHtml = opts.error ? `

${opts.error}

` : ''; - - return layout('Upload', ` -
-

Upload a file

- ${errorHtml} -
- - -
-
- `, { authed: true }); -} - -export function uploadResultPage(shareUrl: string, filename: string): string { - const safeUrl = escHtml(shareUrl); - const safeName = escHtml(filename); - - return layout('File uploaded', ` -
-

File uploaded

-

${safeName} is ready to share.

-
- - -
-

Upload another · My files

-
- `, { authed: true }); -} diff --git a/views/_layout.ejs b/views/_layout.ejs new file mode 100644 index 0000000..021586a --- /dev/null +++ b/views/_layout.ejs @@ -0,0 +1,29 @@ + + + + + + + <%= title %> — Nanodrop + + + +<% if (!hideHeader) { %> +
+ + <% if (authed) { %> + + <% } %> +
+<% } %> +
+ <%- body %> +
+ + diff --git a/views/file-list.ejs b/views/file-list.ejs new file mode 100644 index 0000000..c672ce0 --- /dev/null +++ b/views/file-list.ejs @@ -0,0 +1,31 @@ +

My files

+

Upload new file

+
+ + + + + + + + <% if (files.length === 0) { %> + + <% } else { %> + <% files.forEach(function(f) { %> + + + + + + + + <% }); %> + <% } %> + +
NameTypeSizeUploaded
No files yet. Upload one.
<%= f.original_name %><%= f.mime_type %><%= f.sizeFormatted %><%= f.created_at %> + +
+ +
+
+
diff --git a/views/file-view.ejs b/views/file-view.ejs new file mode 100644 index 0000000..61b7a97 --- /dev/null +++ b/views/file-view.ejs @@ -0,0 +1,19 @@ +
+

<%= file.original_name %>

+ <% if (file.mime_type.startsWith('image/')) { %> + <%= file.original_name %> + <% } else if (file.mime_type.startsWith('video/')) { %> + + <% } else if (file.mime_type.startsWith('audio/')) { %> + + <% } %> +
+ Download + Open +
+ <% if (isOwner) { %> +
+ +
+ <% } %> +
diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..c9351b0 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,17 @@ +
+

Sign in

+ <% if (error) { %> +

<%= error %>

+ <% } %> +
+ + + +
+
diff --git a/views/not-found.ejs b/views/not-found.ejs new file mode 100644 index 0000000..e944740 --- /dev/null +++ b/views/not-found.ejs @@ -0,0 +1,5 @@ +
+

404 — Not found

+

The page or file you requested does not exist.

+

Go home

+
diff --git a/views/upload-result.ejs b/views/upload-result.ejs new file mode 100644 index 0000000..6fcdcbe --- /dev/null +++ b/views/upload-result.ejs @@ -0,0 +1,9 @@ +
+

File uploaded

+

<%= filename %> is ready to share.

+
+ + +
+

Upload another · My files

+
diff --git a/views/upload.ejs b/views/upload.ejs new file mode 100644 index 0000000..71d389a --- /dev/null +++ b/views/upload.ejs @@ -0,0 +1,13 @@ +
+

Upload a file

+ <% if (error) { %> +

<%= error %>

+ <% } %> +
+ + +
+