feat(katalog): set-katalog mit filtern, detailseite und uploads
Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag ohne collection_item mit Status ungleich sold, als korrelierte Subquery, damit mehrere Exemplare die Liste nicht verdoppeln. Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den Aktionen "Zur Sammlung" und "Auf Wunschliste". Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs, erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder oeffentlich, Fotos und PDFs nur mit Session. Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und 4 Sammlungseintraegen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
/** URL for a file stored under DATA_DIR/files. */
|
||||
export function fileUrl(relativePath: string): string {
|
||||
return `/api/files/${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picture for a set: a locally stored upload wins over an external URL, since
|
||||
* it keeps working when the shop takes its image offline.
|
||||
*/
|
||||
export function setImageSrc(input: {
|
||||
imagePath?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}): string | null {
|
||||
if (input.imagePath) return fileUrl(input.imagePath);
|
||||
return input.imageUrl ?? null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { CollectionStatus, Completeness, Condition, Occasion } from '@/db/types';
|
||||
|
||||
/** German labels for the enum values. The database keeps English keys. */
|
||||
|
||||
export const STATUS_LABELS: Record<CollectionStatus, string> = {
|
||||
owned: 'Vorhanden',
|
||||
built: 'Aufgebaut',
|
||||
disassembled: 'Zerlegt',
|
||||
lent_out: 'Verliehen',
|
||||
sold: 'Verkauft',
|
||||
};
|
||||
|
||||
export const COMPLETENESS_LABELS: Record<Completeness, string> = {
|
||||
complete: 'Vollständig',
|
||||
missing_parts: 'Teile fehlen',
|
||||
unknown: 'Unbekannt',
|
||||
};
|
||||
|
||||
export const CONDITION_LABELS: Record<Condition, string> = {
|
||||
new_sealed: 'Neu und versiegelt',
|
||||
like_new: 'Wie neu',
|
||||
good: 'Gut',
|
||||
used: 'Gebraucht',
|
||||
damaged: 'Beschädigt',
|
||||
};
|
||||
|
||||
export const OCCASION_LABELS: Record<Occasion, string> = {
|
||||
christmas: 'Weihnachten',
|
||||
birthday: 'Geburtstag',
|
||||
easter: 'Ostern',
|
||||
other: 'Anderer Anlass',
|
||||
};
|
||||
|
||||
export function statusLabel(status: CollectionStatus): string {
|
||||
return STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function completenessLabel(value: Completeness): string {
|
||||
return COMPLETENESS_LABELS[value];
|
||||
}
|
||||
|
||||
export function conditionLabel(value: Condition | null): string {
|
||||
return value ? CONDITION_LABELS[value] : '–';
|
||||
}
|
||||
|
||||
export function occasionLabel(value: Occasion): string {
|
||||
return OCCASION_LABELS[value];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
import type { CollectionStatus } from '@/db/types';
|
||||
|
||||
/**
|
||||
* The single definition of "he has it".
|
||||
*
|
||||
* A catalog set counts as owned as soon as one collection item exists whose
|
||||
* status is anything but `sold`. A sold copy is history, not ownership - so a
|
||||
* set that was sold shows up under "fehlt" again and can go back on a wishlist.
|
||||
*
|
||||
* Every query that asks about ownership goes through this module. If the rule
|
||||
* ever changes, it changes here.
|
||||
*/
|
||||
|
||||
export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const;
|
||||
|
||||
export const SOLD_STATUS: CollectionStatus = 'sold';
|
||||
|
||||
export type OwnedStatus = (typeof OWNED_STATUSES)[number];
|
||||
|
||||
/** Does a single collection item count towards ownership? */
|
||||
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
|
||||
return status !== SOLD_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a set count as owned, given the statuses of all its collection items?
|
||||
* An empty list means the set is only in the catalog.
|
||||
*/
|
||||
export function isSetOwned(statuses: readonly CollectionStatus[]): boolean {
|
||||
return statuses.some(isOwningStatus);
|
||||
}
|
||||
|
||||
/** How many copies count as owned. Several rows for one set are allowed. */
|
||||
export function countOwnedCopies(statuses: readonly CollectionStatus[]): number {
|
||||
return statuses.filter(isOwningStatus).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL counterpart of `countOwnedCopies`, as a correlated subquery.
|
||||
*
|
||||
* A subquery instead of a join, because a set owned twice must not appear
|
||||
* twice in the catalog list.
|
||||
*/
|
||||
export function ownedCopiesSql(setIdColumn: SQL | unknown): SQL<number> {
|
||||
return sql<number>`(
|
||||
select count(*) from collection_item ci
|
||||
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
|
||||
)`;
|
||||
}
|
||||
|
||||
/** How many wishlists this set is currently on. */
|
||||
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> {
|
||||
return sql<number>`(
|
||||
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
|
||||
)`;
|
||||
}
|
||||
|
||||
/** The four states the catalog filter offers, as they appear in the URL. */
|
||||
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
|
||||
|
||||
export type OwnershipFilter = (typeof OWNERSHIP_FILTERS)[number];
|
||||
|
||||
export function parseOwnershipFilter(value: string | undefined | null): OwnershipFilter {
|
||||
return OWNERSHIP_FILTERS.includes(value as OwnershipFilter)
|
||||
? (value as OwnershipFilter)
|
||||
: 'alle';
|
||||
}
|
||||
|
||||
export const OWNERSHIP_FILTER_LABELS: Record<OwnershipFilter, string> = {
|
||||
alle: 'Alle Sets',
|
||||
sammlung: 'In der Sammlung',
|
||||
fehlt: 'Fehlt noch',
|
||||
wunschliste: 'Auf einer Wunschliste',
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'server-only';
|
||||
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, instruction, manufacturer, photo, set, theme } from '@/db/schema';
|
||||
import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
|
||||
|
||||
export const CATALOG_PAGE_SIZE = 48;
|
||||
|
||||
export type CatalogFilters = {
|
||||
q?: string;
|
||||
manufacturerId?: string;
|
||||
themeId?: string;
|
||||
year?: number;
|
||||
ownership?: OwnershipFilter;
|
||||
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
|
||||
excludeWishlistId?: string;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
export type CatalogRow = {
|
||||
id: string;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
imageUrl: string | null;
|
||||
imagePath: string | null;
|
||||
retailPriceCents: number | null;
|
||||
manufacturerName: string;
|
||||
themeName: string | null;
|
||||
ownedCopies: number;
|
||||
wishlistCount: number;
|
||||
};
|
||||
|
||||
function buildWhere(filters: CatalogFilters): SQL | undefined {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
const q = filters.q?.trim();
|
||||
if (q) {
|
||||
// Matching set number and name covers how people actually search: either
|
||||
// they read the number off the box, or they remember the name.
|
||||
const pattern = `%${q.toLowerCase()}%`;
|
||||
const match = or(
|
||||
like(sql`lower(${set.setNumber})`, pattern),
|
||||
like(sql`lower(${set.name})`, pattern),
|
||||
like(sql`lower(coalesce(${set.ean}, ''))`, pattern),
|
||||
);
|
||||
if (match) conditions.push(match);
|
||||
}
|
||||
|
||||
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
|
||||
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
|
||||
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
|
||||
|
||||
const owned = ownedCopiesSql(set.id);
|
||||
switch (filters.ownership) {
|
||||
case 'sammlung':
|
||||
conditions.push(sql`${owned} > 0`);
|
||||
break;
|
||||
case 'fehlt':
|
||||
conditions.push(sql`${owned} = 0`);
|
||||
break;
|
||||
case 'wunschliste':
|
||||
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (filters.excludeWishlistId) {
|
||||
conditions.push(
|
||||
sql`not exists (
|
||||
select 1 from wishlist_item wi
|
||||
where wi.set_id = ${set.id} and wi.wishlist_id = ${filters.excludeWishlistId}
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
|
||||
export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total: number } {
|
||||
const where = buildWhere(filters);
|
||||
const page = Math.max(1, filters.page ?? 1);
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
id: set.id,
|
||||
setNumber: set.setNumber,
|
||||
name: set.name,
|
||||
year: set.year,
|
||||
pieceCount: set.pieceCount,
|
||||
imageUrl: set.imageUrl,
|
||||
imagePath: set.imagePath,
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
manufacturerName: manufacturer.name,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||
.where(where)
|
||||
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
||||
.limit(CATALOG_PAGE_SIZE)
|
||||
.offset((page - 1) * CATALOG_PAGE_SIZE)
|
||||
.all();
|
||||
|
||||
const [totals] = db
|
||||
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
||||
.from(set)
|
||||
.where(where)
|
||||
.all();
|
||||
|
||||
return { rows, total: totals?.total ?? 0 };
|
||||
}
|
||||
|
||||
/** All set ids matching the current filters - used by "alle auswählen". */
|
||||
export function listSetIds(filters: CatalogFilters): string[] {
|
||||
return db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(buildWhere(filters))
|
||||
.all()
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
export function getSetById(id: string) {
|
||||
const row = db
|
||||
.select({
|
||||
id: set.id,
|
||||
manufacturerId: set.manufacturerId,
|
||||
manufacturerName: manufacturer.name,
|
||||
manufacturerWebsite: manufacturer.website,
|
||||
themeId: set.themeId,
|
||||
themeName: theme.name,
|
||||
setNumber: set.setNumber,
|
||||
name: set.name,
|
||||
year: set.year,
|
||||
pieceCount: set.pieceCount,
|
||||
minifigCount: set.minifigCount,
|
||||
ean: set.ean,
|
||||
ageRange: set.ageRange,
|
||||
imageUrl: set.imageUrl,
|
||||
imagePath: set.imagePath,
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
notes: set.notes,
|
||||
createdAt: set.createdAt,
|
||||
updatedAt: set.updatedAt,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||
.where(eq(set.id, id))
|
||||
.get();
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function getSetByNumber(manufacturerId: string, setNumber: string) {
|
||||
return (
|
||||
db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function listInstructionsForSet(setId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(instruction)
|
||||
.where(eq(instruction.setId, setId))
|
||||
.orderBy(asc(instruction.sortOrder), asc(instruction.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function listPhotosForSet(setId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(photo)
|
||||
.where(eq(photo.setId, setId))
|
||||
.orderBy(asc(photo.sortOrder), asc(photo.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function listCollectionItemsForSet(setId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(collectionItem)
|
||||
.where(eq(collectionItem.setId, setId))
|
||||
.orderBy(desc(collectionItem.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Distinct release years, newest first - fills the year filter. */
|
||||
export function listSetYears(): number[] {
|
||||
return db
|
||||
.selectDistinct({ year: set.year })
|
||||
.from(set)
|
||||
.where(not(sql`${set.year} is null`))
|
||||
.orderBy(desc(set.year))
|
||||
.all()
|
||||
.flatMap((row) => (row.year === null ? [] : [row.year]));
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'server-only';
|
||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
|
||||
/** Short list for the "auf welche Wunschliste?" dialogs. */
|
||||
export function listWishlistTargets() {
|
||||
return db
|
||||
.select({
|
||||
id: wishlist.id,
|
||||
title: wishlist.title,
|
||||
itemCount: count(wishlistItem.id),
|
||||
})
|
||||
.from(wishlist)
|
||||
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
||||
.groupBy(wishlist.id)
|
||||
.orderBy(asc(wishlist.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function listWishlists(ownerUserId: string) {
|
||||
const openItems = sql<number>`(
|
||||
select count(*) from wishlist_item wi
|
||||
where wi.wishlist_id = ${wishlist.id} and wi.reserved_at is null and wi.fulfilled_at is null
|
||||
)`.mapWith(Number);
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: wishlist.id,
|
||||
title: wishlist.title,
|
||||
occasion: wishlist.occasion,
|
||||
eventDate: wishlist.eventDate,
|
||||
isPublic: wishlist.isPublic,
|
||||
surpriseMode: wishlist.surpriseMode,
|
||||
shareToken: wishlist.shareToken,
|
||||
createdAt: wishlist.createdAt,
|
||||
itemCount: count(wishlistItem.id),
|
||||
openCount: openItems,
|
||||
})
|
||||
.from(wishlist)
|
||||
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
||||
.where(eq(wishlist.ownerUserId, ownerUserId))
|
||||
.groupBy(wishlist.id)
|
||||
.orderBy(asc(wishlist.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getWishlist(id: string) {
|
||||
return db.select().from(wishlist).where(eq(wishlist.id, id)).get() ?? null;
|
||||
}
|
||||
|
||||
export function getWishlistByShareToken(shareToken: string) {
|
||||
return db.select().from(wishlist).where(eq(wishlist.shareToken, shareToken)).get() ?? null;
|
||||
}
|
||||
|
||||
export type WishlistItemRow = {
|
||||
id: string;
|
||||
setId: string;
|
||||
priority: number;
|
||||
note: string | null;
|
||||
sortOrder: number;
|
||||
reservedByName: string | null;
|
||||
reservedAt: Date | null;
|
||||
fulfilledAt: Date | null;
|
||||
setNumber: string;
|
||||
setName: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
imageUrl: string | null;
|
||||
imagePath: string | null;
|
||||
retailPriceCents: number | null;
|
||||
manufacturerName: string;
|
||||
manufacturerWebsite: string | null;
|
||||
themeName: string | null;
|
||||
ownedCopies: number;
|
||||
};
|
||||
|
||||
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
||||
return db
|
||||
.select({
|
||||
id: wishlistItem.id,
|
||||
setId: wishlistItem.setId,
|
||||
priority: wishlistItem.priority,
|
||||
note: wishlistItem.note,
|
||||
sortOrder: wishlistItem.sortOrder,
|
||||
reservedByName: wishlistItem.reservedByName,
|
||||
reservedAt: wishlistItem.reservedAt,
|
||||
fulfilledAt: wishlistItem.fulfilledAt,
|
||||
setNumber: set.setNumber,
|
||||
setName: set.name,
|
||||
year: set.year,
|
||||
pieceCount: set.pieceCount,
|
||||
imageUrl: set.imageUrl,
|
||||
imagePath: set.imagePath,
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
manufacturerName: manufacturer.name,
|
||||
manufacturerWebsite: manufacturer.website,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
})
|
||||
.from(wishlistItem)
|
||||
.innerJoin(set, eq(set.id, wishlistItem.setId))
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||
.where(eq(wishlistItem.wishlistId, wishlistId))
|
||||
.orderBy(asc(wishlistItem.sortOrder), asc(wishlistItem.priority), asc(set.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Which of these sets are already on the list - drives the duplicate skip. */
|
||||
export function findExistingWishlistSetIds(wishlistId: string, setIds: string[]): Set<string> {
|
||||
if (setIds.length === 0) return new Set();
|
||||
|
||||
const rows = db
|
||||
.select({ setId: wishlistItem.setId })
|
||||
.from(wishlistItem)
|
||||
.where(and(eq(wishlistItem.wishlistId, wishlistId), inArray(wishlistItem.setId, setIds)))
|
||||
.all();
|
||||
|
||||
return new Set(rows.map((row) => row.setId));
|
||||
}
|
||||
|
||||
export function nextWishlistSortOrder(wishlistId: string): number {
|
||||
const rows = db
|
||||
.select({ sortOrder: wishlistItem.sortOrder })
|
||||
.from(wishlistItem)
|
||||
.where(eq(wishlistItem.wishlistId, wishlistId))
|
||||
.all();
|
||||
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
|
||||
}
|
||||
|
||||
/** Wishlists a set appears on - shown on the set detail page. */
|
||||
export function listWishlistsForSet(setId: string) {
|
||||
return db
|
||||
.select({ id: wishlist.id, title: wishlist.title, itemId: wishlistItem.id })
|
||||
.from(wishlistItem)
|
||||
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
|
||||
.where(eq(wishlistItem.setId, setId))
|
||||
.orderBy(asc(wishlist.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks wishlist entries as fulfilled once the set is in the collection, and
|
||||
* clears the flag again if the copy was sold. Called after every change to the
|
||||
* collection, so the lists stay honest without a background job.
|
||||
*/
|
||||
export function reconcileFulfilledWishlistItems(): void {
|
||||
const owned = ownedCopiesSql(wishlistItem.setId);
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ fulfilledAt: new Date() })
|
||||
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
|
||||
.run();
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ fulfilledAt: null })
|
||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Tokens for the parts of the app that work without an account: the wishlist
|
||||
* share link and the reservation cancel link.
|
||||
*
|
||||
* 32 random bytes, base64url encoded. That is 256 bits of entropy - guessing
|
||||
* one is not a threat model, and the encoded form survives being pasted into
|
||||
* a chat message without escaping.
|
||||
*/
|
||||
export const TOKEN_BYTES = 32;
|
||||
|
||||
export function createToken(): string {
|
||||
return randomBytes(TOKEN_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reservation and invite tokens are stored as a hash, so a database dump does
|
||||
* not let anyone cancel someone else's reservation. The share token is stored
|
||||
* in plain text on purpose - the owner has to be able to see it again.
|
||||
*/
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two hex hashes. */
|
||||
export function tokensMatch(token: string, expectedHash: string | null | undefined): boolean {
|
||||
if (!expectedHash) return false;
|
||||
|
||||
const actual = Buffer.from(hashToken(token), 'hex');
|
||||
const expected = Buffer.from(expectedHash, 'hex');
|
||||
if (actual.length !== expected.length) return false;
|
||||
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'server-only';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { env } from '@/env';
|
||||
|
||||
/**
|
||||
* Everything that writes into DATA_DIR/files.
|
||||
*
|
||||
* Rules that hold for every upload:
|
||||
* - the file name is generated here, never taken from the client;
|
||||
* - the type is verified by magic bytes, not by the declared MIME type;
|
||||
* - the resulting path is checked to stay inside FILES_DIR.
|
||||
*/
|
||||
|
||||
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
export const PDF_MIME_TYPE = 'application/pdf';
|
||||
|
||||
export type UploadKind = 'set-image' | 'photo' | 'instruction';
|
||||
|
||||
export class UploadError extends Error {}
|
||||
|
||||
type Signature = { mime: string; test: (bytes: Uint8Array) => boolean };
|
||||
|
||||
const SIGNATURES: Signature[] = [
|
||||
{
|
||||
mime: 'application/pdf',
|
||||
test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46,
|
||||
},
|
||||
{
|
||||
mime: 'image/jpeg',
|
||||
test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
||||
},
|
||||
{
|
||||
mime: 'image/png',
|
||||
test: (b) =>
|
||||
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d &&
|
||||
b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
|
||||
},
|
||||
{
|
||||
mime: 'image/webp',
|
||||
test: (b) =>
|
||||
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
||||
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
|
||||
},
|
||||
];
|
||||
|
||||
/** Reads the real type from the first bytes. Returns null for anything else. */
|
||||
export function detectMimeType(bytes: Uint8Array): string | null {
|
||||
return SIGNATURES.find((signature) => signature.test(bytes))?.mime ?? null;
|
||||
}
|
||||
|
||||
function assertSize(file: File) {
|
||||
if (file.size === 0) throw new UploadError('Die Datei ist leer.');
|
||||
if (file.size > env.MAX_UPLOAD_BYTES) {
|
||||
throw new UploadError(
|
||||
`Die Datei ist größer als ${env.MAX_UPLOAD_MB} MB. Größere Dateien sind nicht erlaubt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute path for a path stored in the database, with traversal check. */
|
||||
export function resolveFilePath(relativePath: string): string {
|
||||
const absolute = path.resolve(env.FILES_DIR, relativePath);
|
||||
const root = path.resolve(env.FILES_DIR);
|
||||
if (absolute !== root && !absolute.startsWith(root + path.sep)) {
|
||||
throw new UploadError('Ungültiger Dateipfad.');
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
async function writeFile(relativePath: string, data: Buffer): Promise<void> {
|
||||
const absolute = resolveFilePath(relativePath);
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, data);
|
||||
}
|
||||
|
||||
export type StoredImage = {
|
||||
/** Path relative to FILES_DIR, as stored in the database. */
|
||||
filePath: string;
|
||||
thumbnailPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalises an uploaded image to WebP plus a thumbnail. Large photos from a
|
||||
* phone camera shrink from several MB to a few hundred KB, which matters on a
|
||||
* self-hosted box with a small disk.
|
||||
*/
|
||||
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
|
||||
assertSize(file);
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const mime = detectMimeType(bytes);
|
||||
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
|
||||
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const folder = kind === 'set-image' ? `images/sets/${ownerId}` : `photos/${ownerId}`;
|
||||
const filePath = `${folder}/${id}.webp`;
|
||||
const thumbnailPath = `${folder}/${id}.thumb.webp`;
|
||||
|
||||
const [full, thumbnail] = await Promise.all([
|
||||
sharp(bytes).rotate().resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(),
|
||||
sharp(bytes).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 70 }).toBuffer(),
|
||||
]);
|
||||
|
||||
await writeFile(filePath, full);
|
||||
await writeFile(thumbnailPath, thumbnail);
|
||||
|
||||
return { filePath, thumbnailPath };
|
||||
}
|
||||
|
||||
export type StoredPdf = {
|
||||
filePath: string;
|
||||
fileSizeBytes: number;
|
||||
};
|
||||
|
||||
export async function storePdf(file: File, setId: string): Promise<StoredPdf> {
|
||||
assertSize(file);
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
if (detectMimeType(bytes) !== PDF_MIME_TYPE) {
|
||||
throw new UploadError('Anleitungen müssen PDF-Dateien sein.');
|
||||
}
|
||||
|
||||
const filePath = `instructions/${setId}/${crypto.randomUUID()}.pdf`;
|
||||
await writeFile(filePath, bytes);
|
||||
|
||||
return { filePath, fileSizeBytes: bytes.byteLength };
|
||||
}
|
||||
|
||||
/** Removes a stored file. Missing files are not an error - the row is going away anyway. */
|
||||
export async function deleteStoredFile(relativePath: string | null | undefined): Promise<void> {
|
||||
if (!relativePath) return;
|
||||
try {
|
||||
await fs.unlink(resolveFilePath(relativePath));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[uploads] Datei konnte nicht gelöscht werden', relativePath, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Files below images/ are readable without a session (see CLAUDE.md). */
|
||||
export function isPublicFilePath(relativePath: string): boolean {
|
||||
return relativePath.startsWith('images/');
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.webp': 'image/webp',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export function contentTypeFor(relativePath: string): string {
|
||||
return CONTENT_TYPES[path.extname(relativePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pure rules for putting sets on a wishlist. Kept free of database access so
|
||||
* they can be unit tested directly - they are the part that is easy to get
|
||||
* subtly wrong.
|
||||
*/
|
||||
|
||||
export type BulkAddPlan = {
|
||||
/** Sets that will be inserted. */
|
||||
toAdd: string[];
|
||||
/** Already on this list - skipped, and named in the result toast. */
|
||||
duplicates: string[];
|
||||
/** Already in the collection - a wish for something he has makes no sense. */
|
||||
owned: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a selection into the sets that actually get added and the two kinds
|
||||
* of leftovers. Input order is preserved and repeated ids collapse, so a
|
||||
* double tap in the selection UI cannot insert a set twice.
|
||||
*/
|
||||
export function planBulkAdd(
|
||||
selectedSetIds: readonly string[],
|
||||
existingSetIds: ReadonlySet<string>,
|
||||
ownedSetIds: ReadonlySet<string> = new Set(),
|
||||
): BulkAddPlan {
|
||||
const seen = new Set<string>();
|
||||
const plan: BulkAddPlan = { toAdd: [], duplicates: [], owned: [] };
|
||||
|
||||
for (const setId of selectedSetIds) {
|
||||
if (seen.has(setId)) continue;
|
||||
seen.add(setId);
|
||||
|
||||
if (existingSetIds.has(setId)) plan.duplicates.push(setId);
|
||||
else if (ownedSetIds.has(setId)) plan.owned.push(setId);
|
||||
else plan.toAdd.push(setId);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** German summary for the toast after a bulk add. */
|
||||
export function describeBulkAdd(plan: BulkAddPlan, names: Map<string, string>): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(
|
||||
plan.toAdd.length === 1
|
||||
? '1 Set hinzugefügt'
|
||||
: `${plan.toAdd.length} Sets hinzugefügt`,
|
||||
);
|
||||
|
||||
if (plan.duplicates.length > 0) {
|
||||
parts.push(`${plan.duplicates.length} übersprungen (schon auf der Liste): ${nameList(plan.duplicates, names)}`);
|
||||
}
|
||||
if (plan.owned.length > 0) {
|
||||
parts.push(`${plan.owned.length} übersprungen (schon in der Sammlung): ${nameList(plan.owned, names)}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Lists up to three names and summarises the rest. */
|
||||
function nameList(ids: readonly string[], names: Map<string, string>): string {
|
||||
const labels = ids.map((id) => names.get(id) ?? id);
|
||||
if (labels.length <= 3) return labels.join(', ');
|
||||
return `${labels.slice(0, 3).join(', ')} und ${labels.length - 3} weitere`;
|
||||
}
|
||||
|
||||
/** 1 = highest. Used for badges and sorting. */
|
||||
export const PRIORITY_LABELS: Record<number, string> = {
|
||||
1: 'Großer Wunsch',
|
||||
2: 'Gerne',
|
||||
3: 'Irgendwann',
|
||||
};
|
||||
|
||||
export function priorityLabel(priority: number): string {
|
||||
return PRIORITY_LABELS[priority] ?? PRIORITY_LABELS[2]!;
|
||||
}
|
||||
Reference in New Issue
Block a user