Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions apps/server/src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import path from 'node:path';
import type { Request as ExpressRequest, Response, NextFunction, RequestHandler } from 'express';

/** Express 4 doesn't forward async rejections to the error handler. */
export function wrap(fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler {
export function wrap(fn: (req: ExpressRequest, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
Expand All @@ -12,3 +13,55 @@ export function intParam(value: string): number {
if (!Number.isInteger(n) || n < 0) throw Object.assign(new Error('Invalid id'), { status: 400 });
return n;
}

/** Buffer the raw body and let Bun's fetch primitives parse the multipart form. */
export async function readFormData(req: ExpressRequest) {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const request = new Request('http://localhost' + req.originalUrl, {
method: 'POST',
headers: { 'content-type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
});
return request.formData();
}

/** The File parts of a form field; string parts are dropped. */
export function formFiles(form: Awaited<ReturnType<typeof readFormData>>, field: string) {
return form.getAll(field).filter((f) => typeof f !== 'string');
}

/**
* A client-supplied filename made safe to join onto a directory. The name comes straight from the
* multipart header, so a crafted request can send `../../x` and write outside the target folder.
*/
export function sanitizeFilename(name: string): string {
return path
.basename(name)
.replace(/[^\w.\-()[\] ]+/g, '_')
.slice(-120);
}

/**
* Start a server-sent event stream. `send` writes one JSON event; the returned cleanup hook runs
* once the client disconnects, alongside stopping the keep-alive ping.
*/
export function openEventStream(
req: ExpressRequest,
res: Response,
): { send: (event: unknown) => void; onClose: (fn: () => void) => void } {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);
const cleanups: (() => void)[] = [() => clearInterval(heartbeat)];
req.on('close', () => {
for (const fn of cleanups) fn();
});
return {
send: (event) => res.write(`data: ${JSON.stringify(event)}\n\n`),
onClose: (fn) => cleanups.push(fn),
};
}
42 changes: 41 additions & 1 deletion apps/server/src/lib/rowToMedia.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import type { Media } from '@sakuya/shared';

/**
* A media row joined with its library name and tag count — the shape rowToMedia expects. Append
* WHERE/ORDER BY; the media table is aliased `m`.
*/
export const MEDIA_SELECT = `
SELECT m.*, l.name AS library_name,
(SELECT COUNT(*) FROM media_tags mt WHERE mt.media_id = m.id) AS tag_count
FROM media m LEFT JOIN libraries l ON l.id = m.library_id`;

/** A raw sqlite media row: snake_case columns plus the joined ones some queries add. */
export interface MediaSqlRow {
id: number;
library_id: number;
source: Media['source'];
path: string;
filename: string;
type: Media['type'];
width: number | null;
height: number | null;
size_bytes: number;
duration_seconds: number | null;
content_hash: string | null;
created_at: number;
indexed_at: number | null;
tagged_at: number | null;
last_viewed_at: number | null;
view_progress: number;
view_count: number;
watched_seconds: number;
dwell_seconds: number;
liked: number;
liked_at: number | null;
perceptual_hash: string | null;
tag_count?: number;
library_name?: string | null;
sort_key?: number | string;
reason_tag?: string | null;
reason_related?: number | null;
}

/** Maps a raw sqlite media row (snake_case, joined columns) to the shared Media shape. */
export function rowToMedia(row: any): Media {
export function rowToMedia(row: MediaSqlRow): Media {
return {
id: row.id,
libraryId: row.library_id,
Expand Down
98 changes: 98 additions & 0 deletions apps/server/src/requestValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, test } from 'bun:test';
import fs from 'node:fs';
import path from 'node:path';
import { useTestConfig } from './testConfig';

useTestConfig('validation', { port: 38780 });
const BASE = 'http://localhost:38780';

const { db, sqlite, schema } = await import('./db');
const { DOWNLOADER_COOKIES_DIR, DOWNLOADER_DIR } = await import('./lib/config');
await import('./index');

const send = (method: string, url: string, body: unknown) =>
fetch(BASE + url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });

test('an uploaded cookie file cannot be written outside the cookies folder', async () => {
const form = new FormData();
form.append('files', new File(['# Netscape HTTP Cookie File'], '../../../escaped.txt'));
const res = await fetch(BASE + '/api/downloader/cookies', { method: 'POST', body: form });
expect(res.status).toBe(201);

const row = db.select().from(schema.downloadCookies).get()!;
expect(path.dirname(row.storedPath)).toBe(DOWNLOADER_COOKIES_DIR);
expect(fs.existsSync(row.storedPath)).toBe(true);
expect(fs.existsSync(path.join(DOWNLOADER_DIR, 'escaped.txt'))).toBe(false);
});

test('adding a deleted media id to a board skips it instead of failing the whole add', async () => {
const lib = db.insert(schema.libraries).values({ name: 'lib', createdAt: Date.now() }).returning().get();
const media = db
.insert(schema.media)
.values({
libraryId: lib.id,
source: 'upload',
path: '/tmp/kept.png',
filename: 'kept.png',
type: 'image',
createdAt: Date.now(),
})
.returning()
.get();
const board = (await (await send('POST', '/api/boards', { name: 'b' })).json()) as { id: number };

const res = await send('POST', `/api/boards/${board.id}/media`, { mediaIds: [media.id, 999_999] });
expect(res.status).toBe(200);
expect(((await res.json()) as { itemCount: number }).itemCount).toBe(1);

const removed = await send('POST', `/api/boards/${board.id}/media/remove-batch`, { mediaIds: [media.id, 999_999] });
expect(((await removed.json()) as { itemCount: number }).itemCount).toBe(0);
});

test('a settings patch with one unknown key changes nothing', async () => {
const before = sqlite.query(`SELECT value FROM settings WHERE key = 'accent_color'`).get() as {
value: string;
} | null;
const res = await send('PATCH', '/api/settings', { accent_color: '#123456', not_a_setting: '1' });
expect(res.status).toBe(400);
const after = sqlite.query(`SELECT value FROM settings WHERE key = 'accent_color'`).get() as { value: string } | null;
expect(after?.value).toBe(before?.value);
});

test('tag search treats _ and % as literal characters', async () => {
const lib = db.insert(schema.libraries).values({ name: 'tags', createdAt: Date.now() }).returning().get();
const media = db
.insert(schema.media)
.values({
libraryId: lib.id,
source: 'upload',
path: '/tmp/tagged.png',
filename: 'tagged.png',
type: 'image',
createdAt: Date.now(),
})
.returning()
.get();
await send('PATCH', `/api/media/${media.id}/tags`, { add: ['long_hair', 'longxhair', '100%_done'] });

const names = async (url: string) =>
((await (await fetch(BASE + url)).json()) as { name: string }[]).map((t) => t.name).sort();
for (const scope of ['', `&libraryId=${lib.id}`]) {
expect(await names(`/api/tags?q=long_h${scope}`)).toEqual(['long_hair']);
expect(await names(`/api/tags?q=${encodeURIComponent('0%_')}${scope}`)).toEqual(['100%_done']);
}
});

test('attaching the same folder twice is refused', async () => {
const lib = db.insert(schema.libraries).values({ name: 'folders', createdAt: Date.now() }).returning().get();
const dir = fs.mkdtempSync(path.join(DOWNLOADER_DIR, 'folder-'));
expect((await send('POST', `/api/libraries/${lib.id}/folders`, { path: dir })).status).toBe(201);
expect((await send('POST', `/api/libraries/${lib.id}/folders`, { path: dir })).status).toBe(409);
});

test('system info reports the thumbnail folder size', async () => {
const { THUMBS_DIR } = await import('./lib/config');
fs.writeFileSync(path.join(THUMBS_DIR, 'size-probe.webp'), Buffer.alloc(1234));
const info = (await (await fetch(BASE + '/api/system')).json()) as { thumbBytes: number };
expect(info.thumbBytes).toBeGreaterThanOrEqual(1234);
});
28 changes: 18 additions & 10 deletions apps/server/src/routes/boards.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Router } from 'express';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db, sqlite, schema } from '../db';
import { wrap, intParam } from '../lib/http';
import { chunkIds } from '../lib/mediaByIds';
import type { BoardWithStats } from '@sakuya/shared';

export const boardsRouter = Router();
Expand Down Expand Up @@ -76,10 +77,18 @@ boardsRouter.post(
if (!board) return res.status(404).json({ error: 'Not found' });
const { mediaIds } = mediaIdsBody.parse(req.body);
const now = Date.now();
db.insert(schema.boardMedia)
.values(mediaIds.map((mediaId) => ({ boardId: id, mediaId, addedAt: now })))
.onConflictDoNothing()
.run();
// Selected from media rather than inserted as given: an id deleted since the client loaded it
// would otherwise fail the foreign key and 500 the whole add.
sqlite.transaction(() => {
for (const part of chunkIds(mediaIds)) {
sqlite
.query(
`INSERT OR IGNORE INTO board_media (board_id, media_id, added_at)
SELECT ?, id, ? FROM media WHERE id IN (${part.map(() => '?').join(',')})`,
)
.run(id, now, ...part);
}
})();
res.json(boardWithStats(board));
}),
);
Expand All @@ -93,14 +102,13 @@ boardsRouter.post(
const board = db.select().from(schema.boards).where(eq(schema.boards.id, id)).get();
if (!board) return res.status(404).json({ error: 'Not found' });
const { mediaIds } = mediaIdsBody.parse(req.body);
const remove = sqlite.transaction(() => {
for (const mediaId of mediaIds) {
sqlite.transaction(() => {
for (const part of chunkIds(mediaIds)) {
db.delete(schema.boardMedia)
.where(and(eq(schema.boardMedia.boardId, id), eq(schema.boardMedia.mediaId, mediaId)))
.where(and(eq(schema.boardMedia.boardId, id), inArray(schema.boardMedia.mediaId, part)))
.run();
}
});
remove();
})();
res.json(boardWithStats(board));
}),
);
Expand Down
13 changes: 4 additions & 9 deletions apps/server/src/routes/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,27 @@ import { Router } from 'express';
import { sqlite } from '../db';
import { wrap } from '../lib/http';
import { libraryWithStats, librariesInOrder } from './libraries';
import { rowToMedia } from '../lib/rowToMedia';
import { rowToMedia, MEDIA_SELECT, type MediaSqlRow } from '../lib/rowToMedia';
import type { DashboardResponse } from '@sakuya/shared';

export const dashboardRouter = Router();

const MEDIA_SELECT = `
SELECT m.*, l.name AS library_name,
(SELECT COUNT(*) FROM media_tags mt WHERE mt.media_id = m.id) AS tag_count
FROM media m LEFT JOIN libraries l ON l.id = m.library_id`;

dashboardRouter.get(
'/api/dashboard',
wrap(async (_req, res) => {
const libs = librariesInOrder();
const continueWatching = (
sqlite
.query(`${MEDIA_SELECT} WHERE m.view_progress > 0.01 AND m.view_progress < 0.98 ORDER BY m.last_viewed_at DESC LIMIT 12`)
.all() as any[]
.all() as MediaSqlRow[]
).map(rowToMedia);
const recentlyViewed = (
sqlite
.query(`${MEDIA_SELECT} WHERE m.last_viewed_at IS NOT NULL ORDER BY m.last_viewed_at DESC LIMIT 12`)
.all() as any[]
.all() as MediaSqlRow[]
).map(rowToMedia);
const recentlyAdded = (
sqlite.query(`${MEDIA_SELECT} ORDER BY m.created_at DESC, m.id DESC LIMIT 12`).all() as any[]
sqlite.query(`${MEDIA_SELECT} ORDER BY m.created_at DESC, m.id DESC LIMIT 12`).all() as MediaSqlRow[]
).map(rowToMedia);
const likedCount = (sqlite.query('SELECT COUNT(*) AS c FROM media WHERE liked = 1').get() as { c: number }).c;
const likedSample = sqlite
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/routes/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Router } from 'express';
import { z } from 'zod';
import { sqlite } from '../db';
import { wrap } from '../lib/http';
import { rowToMedia } from '../lib/rowToMedia';
import type { SQLQueryBindings } from 'bun:sqlite';
import { rowToMedia, type MediaSqlRow } from '../lib/rowToMedia';
import { tasteVersion } from '../lib/tasteVersion';
import type { MediaListResponse } from '@sakuya/shared';

Expand Down Expand Up @@ -220,7 +221,7 @@ discoverRouter.get(
const { maxScore } = snap;

const conds: string[] = [];
const params: unknown[] = [];
const params: SQLQueryBindings[] = [];
if (query.type) {
conds.push('m.type = ?');
params.push(query.type);
Expand All @@ -244,7 +245,7 @@ discoverRouter.get(
: (
sqlite
.query(`SELECT COUNT(*) AS c FROM media m ${conds.length ? 'WHERE ' + conds.join(' AND ') : ''}`)
.get(...(params as any[])) as { c: number }
.get(...params) as { c: number }
).c;

const pageConds = [...conds];
Expand Down Expand Up @@ -277,7 +278,7 @@ discoverRouter.get(
JOIN media m ON m.id = page.id
LEFT JOIN libraries l ON l.id = m.library_id
ORDER BY page.sort_key DESC, m.id DESC`;
const items = sqlite.query(sql).all(...(pageParams as any[]), query.limit) as any[];
const items = sqlite.query(sql).all(...pageParams, query.limit) as MediaSqlRow[];

let nextCursor: string | null = null;
if (items.length === query.limit) {
Expand Down
Loading
Loading