A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
164 files changed
+27190
No files matched your search
@@ -0,0 +1,168 @@
|
||||
-- Klarbild — Initial schema (users, recipes, jobs, items, delivery, sharing, Telegram).
|
||||
-- Applied automatically at startup (numbered SQL files, no ORM magic).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Users ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text UNIQUE NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
|
||||
display_name text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Preset recipes (saved presets) -------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
tasks jsonb NOT NULL DEFAULT '[]'::jsonb, -- e.g. ["clean","format","deliver"]
|
||||
output_format text,
|
||||
orientation text,
|
||||
crop_mode text,
|
||||
dpi int NOT NULL DEFAULT 300,
|
||||
contour_mm numeric,
|
||||
model_key text,
|
||||
delivery text NOT NULL DEFAULT 'library' CHECK (delivery IN ('library','remote','both')),
|
||||
delivery_folder text,
|
||||
custom_instruction text,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Jobs (a batch) --------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
origin text NOT NULL DEFAULT 'web' CHECK (origin IN ('web','telegram')),
|
||||
recipe_snapshot jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','running','paused','done','cancelled')),
|
||||
total int NOT NULL DEFAULT 0,
|
||||
done_count int NOT NULL DEFAULT 0,
|
||||
failed_count int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz
|
||||
);
|
||||
|
||||
-- Folders ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
delivery_folder text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Items (one image in a job) -----------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_id uuid REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
position int NOT NULL DEFAULT 0,
|
||||
status text NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','running','done','failed','skipped')),
|
||||
attempts int NOT NULL DEFAULT 0,
|
||||
error_message text,
|
||||
source_path text,
|
||||
result_path text,
|
||||
filename text,
|
||||
output_px text,
|
||||
source_quality text CHECK (source_quality IN ('original','compressed')),
|
||||
dpi int,
|
||||
has_alpha bool NOT NULL DEFAULT false,
|
||||
model_used text,
|
||||
prompt_used text,
|
||||
cost numeric, -- actual cost, from usage.cost
|
||||
folder_id uuid REFERENCES folders(id) ON DELETE SET NULL,
|
||||
delivery_status text NOT NULL DEFAULT 'none'
|
||||
CHECK (delivery_status IN ('none','pending','delivered','failed')),
|
||||
delivered_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_job_idx ON items(job_id);
|
||||
CREATE INDEX IF NOT EXISTS items_created_idx ON items(created_at DESC);
|
||||
|
||||
-- Print jobs ------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS print_jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
note text,
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','done')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS print_job_items (
|
||||
print_job_id uuid REFERENCES print_jobs(id) ON DELETE CASCADE,
|
||||
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (print_job_id, item_id)
|
||||
);
|
||||
|
||||
-- Share links -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token text UNIQUE NOT NULL,
|
||||
scope_type text NOT NULL CHECK (scope_type IN ('selection','folder')),
|
||||
scope_ref jsonb NOT NULL,
|
||||
expires_at timestamptz,
|
||||
password_hash text,
|
||||
revoked bool NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Telegram ---------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS telegram_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id bigint UNIQUE NOT NULL,
|
||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||
default_recipe_id uuid REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
active bool NOT NULL DEFAULT true,
|
||||
linked_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS telegram_pairing_codes (
|
||||
code text PRIMARY KEY,
|
||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS telegram_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id bigint NOT NULL,
|
||||
media_group_id text,
|
||||
file_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_received_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NOT NULL DEFAULT 'collecting'
|
||||
CHECK (status IN ('collecting','awaiting_recipe','dispatched','discarded')),
|
||||
notice_message_id bigint
|
||||
);
|
||||
|
||||
-- Settings (single row) -----------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
openrouter_key_enc text,
|
||||
delivery_host text,
|
||||
delivery_protocol text CHECK (delivery_protocol IN ('ftps','sftp')),
|
||||
delivery_port int,
|
||||
delivery_user text,
|
||||
delivery_password_enc text,
|
||||
delivery_base_path text,
|
||||
default_dpi int NOT NULL DEFAULT 300,
|
||||
default_crop_mode text NOT NULL DEFAULT 'crop',
|
||||
concurrency int NOT NULL DEFAULT 2,
|
||||
cricut_sheet_cm text NOT NULL DEFAULT '17.1x23.5',
|
||||
monthly_budget numeric,
|
||||
n8n_webhook_url text,
|
||||
telegram_bot_token_enc text,
|
||||
telegram_webhook_secret text
|
||||
);
|
||||
|
||||
-- Models ------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_id text NOT NULL,
|
||||
label text NOT NULL,
|
||||
description text,
|
||||
active bool NOT NULL DEFAULT true,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
supports_alpha bool NOT NULL DEFAULT false,
|
||||
sort int NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Telegram: the job knows the chat that triggered it (for replying back).
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS telegram_chat_id bigint;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Configurable default delivery target folder (instead of hardcoded).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS delivery_default_folder text;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Klarbild — Generation modes, thumbnails, storage management, backup mirror.
|
||||
-- (04) Combining several images + free-text generation, thumbnails for the
|
||||
-- library, "don't clutter up the server" (retention / deleting sources), a
|
||||
-- second backup to a backup mirror.
|
||||
|
||||
-- Job mode: each = every template on its own (as before), compose = several images
|
||||
-- + text into ONE new image, generate = pure free text without a template.
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
|
||||
CHECK (mode IN ('each','compose','generate'));
|
||||
|
||||
-- Items: several source images (compose) + a shrunk preview image.
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text;
|
||||
|
||||
-- Preset recipes can pre-set a mode (for default Telegram presets).
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
|
||||
CHECK (mode IN ('each','compose','generate'));
|
||||
|
||||
-- Storage management ---------------------------------------------------------
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unlimited
|
||||
|
||||
-- Second backup to a backup mirror (SFTP/FTPS, same as the delivery target) ---
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_enabled bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_host text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_protocol text CHECK (mirror_protocol IN ('ftps','sftp'));
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_port int;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_user text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_password_enc text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_base_path text;
|
||||
|
||||
-- Mirror status per item (for visibility/retrying).
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS mirror_status text NOT NULL DEFAULT 'none'
|
||||
CHECK (mirror_status IN ('none','pending','mirrored','failed'));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Telegram: combine/generate — caption as description + an in-between status.
|
||||
ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text;
|
||||
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
|
||||
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
|
||||
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Alternatives/regeneration: group several results under the same origin.
|
||||
-- variant_of points at the "root" item (the first version); alternatives share it.
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS variant_of uuid REFERENCES items(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS items_variant_idx ON items(variant_of);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Several delivery targets (FTP/SFTP sources), e.g. a second delivery target
|
||||
-- folder or a different FTP server. Preset recipes can hardcode one target.
|
||||
CREATE TABLE IF NOT EXISTS delivery_targets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
protocol text CHECK (protocol IN ('ftps','sftp')),
|
||||
host text,
|
||||
port int,
|
||||
username text,
|
||||
password_enc text,
|
||||
base_path text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Preset → fixed target (NULL = the default delivery target from settings).
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS delivery_target_id uuid
|
||||
REFERENCES delivery_targets(id) ON DELETE SET NULL;
|
||||
|
||||
-- Folder → fixed target (optional).
|
||||
ALTER TABLE folders ADD COLUMN IF NOT EXISTS delivery_target_id uuid
|
||||
REFERENCES delivery_targets(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Backup to any target, metadata sidecar files, API token for external access (MCP).
|
||||
|
||||
-- Any extra target can be flagged as a backup target (receives ALL results).
|
||||
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Send metadata along as a companion .md file (prompt, file name, model, …).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
|
||||
-- API token for programmatic access (Klarbild MCP / automations).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
|
||||
|
||||
-- Backup status per target and item (for "backed up to several targets").
|
||||
CREATE TABLE IF NOT EXISTS item_backups (
|
||||
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
|
||||
target text NOT NULL, -- 'mirror' | delivery_target UUID
|
||||
status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, target)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Access rights, private sessions, NSFW switch.
|
||||
|
||||
-- Visibility of the library + privacy.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS library_visibility text NOT NULL DEFAULT 'own'
|
||||
CHECK (library_visibility IN ('own','shared')); -- own = each user sees only their own; shared = everyone sees everything
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS anonymous_generations bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS private_allowed bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS allow_nsfw bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Per user: force private sessions.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS private_forced bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Private jobs: generate only, then forget (not in the library, no delivery/backup,
|
||||
-- no stored prompt, sources deleted immediately, result removed after a short time).
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS private bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Flag models as NSFW-capable (selectable/visible only while allow_nsfw is on).
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS nsfw bool NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Color tags, as in the delivery target (red / orange / green / final).
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS color_tag text
|
||||
CHECK (color_tag IN ('red','orange','green','final'));
|
||||
CREATE INDEX IF NOT EXISTS items_color_tag_idx ON items(color_tag);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Metadata sidecar file (.md) controllable per upload source instead of globally.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS delivery_metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Jobs may now also be "failed" (all items failed) → shown red in the overview.
|
||||
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
|
||||
ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
|
||||
CHECK (status IN ('queued','running','paused','done','cancelled','failed'));
|
||||
|
||||
-- Output format of the image file: global (settings) + an optional preset override.
|
||||
-- "png" = lossless (default, needed for transparency), "jpg" = small, friendly to the delivery target.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS output_ext text NOT NULL DEFAULT 'png'
|
||||
CHECK (output_ext IN ('png','jpg'));
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS output_ext text
|
||||
CHECK (output_ext IN ('png','jpg'));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Presets for passport photos/print sheets ("daycare set", "8× passport photo") ---
|
||||
CREATE TABLE IF NOT EXISTS print_presets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
config jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name);
|
||||
|
||||
-- Telegram: state "waiting for print selection" -----------------------------
|
||||
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
|
||||
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
|
||||
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded'));
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Sending email, password reset, and share links ---------------------------
|
||||
-- All forward-only and idempotent, like the other migrations.
|
||||
|
||||
-- SMTP access. The password is stored AES-256-GCM encrypted, like the other
|
||||
-- secrets (src/lib/crypto.ts) — never in plain text.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_host text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_port integer NOT NULL DEFAULT 465;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_secure boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_user text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_password text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_from text;
|
||||
|
||||
-- How long a new share link is valid by default (0 = unlimited).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS share_default_days integer NOT NULL DEFAULT 30;
|
||||
|
||||
-- Email on the user account: for "forgot password" and optional notices.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email text;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_jobs boolean NOT NULL DEFAULT false;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email)) WHERE email IS NOT NULL;
|
||||
|
||||
-- Password reset. Only the **hash** of the token is stored — reading the
|
||||
-- database alone is not enough to take over an account.
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
token_hash text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS password_resets_user_idx ON password_resets(user_id);
|
||||
|
||||
-- Share links: one image or a collection, publicly reachable at /s/<slug>.
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text UNIQUE NOT NULL,
|
||||
kind text NOT NULL DEFAULT 'item' CHECK (kind IN ('item','collection')),
|
||||
title text,
|
||||
note text,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
allow_download boolean NOT NULL DEFAULT true,
|
||||
-- Optional passphrase (argon2), so a forwarded link alone is not enough.
|
||||
password_hash text,
|
||||
views integer NOT NULL DEFAULT 0,
|
||||
downloads integer NOT NULL DEFAULT 0,
|
||||
last_seen_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_created_by_idx ON shares(created_by);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_items (
|
||||
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
|
||||
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (share_id, item_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS share_items_item_idx ON share_items(item_id);
|
||||
|
||||
-- Accesses per link. The IP is stored only **hashed** (salted with the server's
|
||||
-- secret): this lets you count returning visitors without identifying anyone
|
||||
-- or recovering the address.
|
||||
CREATE TABLE IF NOT EXISTS share_views (
|
||||
id bigserial PRIMARY KEY,
|
||||
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL DEFAULT 'view' CHECK (kind IN ('view','download')),
|
||||
ip_hash text,
|
||||
user_agent text,
|
||||
at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS share_views_share_idx ON share_views(share_id, at DESC);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Make sessions revocable server-side ---------------------------------------
|
||||
-- Until now the session cookie was only HMAC-signed: whoever had it once got
|
||||
-- in with it indefinitely — even after the password was reset or the role
|
||||
-- was taken away. The very step meant to rescue a hijacked account therefore
|
||||
-- did not help.
|
||||
--
|
||||
-- `session_epoch` is incremented on every password change and every role
|
||||
-- change. The cookie carries the value along; once it no longer matches, the
|
||||
-- cookie is no longer valid. A counter instead of a timestamp, because it
|
||||
-- knows no clock-skew inaccuracies.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch integer NOT NULL DEFAULT 0;
|
||||
|
||||
-- For rename assignment: `renames.ts` checks, for every finished image,
|
||||
-- whether a name is already taken — without an index that was a full table scan.
|
||||
CREATE INDEX IF NOT EXISTS items_filename_idx ON items(filename);
|
||||
|
||||
-- Cleanup: both tables grew without bound.
|
||||
CREATE INDEX IF NOT EXISTS share_views_at_idx ON share_views(at);
|
||||
CREATE INDEX IF NOT EXISTS password_resets_expires_idx ON password_resets(expires_at);
|
||||
|
||||
-- Counter for distinct visitors per share link. It used to be computed on every
|
||||
-- load of the admin page via `count(DISTINCT ip_hash)` over share_views — that
|
||||
-- got slow as the table grew, and made the page attackable, since anyone with
|
||||
-- the link could generate an unlimited number of rows.
|
||||
ALTER TABLE shares ADD COLUMN IF NOT EXISTS visitor integer NOT NULL DEFAULT 0;
|
||||
|
||||
-- Only one row per link, visitor and hour: without this limit, a recipient
|
||||
-- (or an <img> tag on some page) could fill the table without bound and run
|
||||
-- the disk out of space.
|
||||
--
|
||||
-- The hour value is supplied at write time instead of computed in the index:
|
||||
-- `date_trunc` on a timestamptz depends on the timezone setting and is
|
||||
-- therefore not immutable enough for an index.
|
||||
ALTER TABLE share_views ADD COLUMN IF NOT EXISTS hour text;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS share_views_once_per_hour_idx
|
||||
ON share_views (share_id, kind, ip_hash, hour)
|
||||
WHERE ip_hash IS NOT NULL AND hour IS NOT NULL;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Print history: what was printed last --------------------------------------
|
||||
--
|
||||
-- Only the **recipe** is stored, not the result: the sheet settings, the
|
||||
-- references to the source images, and a few key figures. An A3 print sheet
|
||||
-- as a PDF is quickly 20–80 MB; a few dozen prints would fill the disk, and
|
||||
-- with files that can be regenerated in seconds at that. The sheet is simply
|
||||
-- recomputed on reprint — deterministic, from the same sources.
|
||||
--
|
||||
-- Row size: roughly 1–3 KB per entry. A thousand prints therefore cost a few
|
||||
-- megabytes instead of many gigabytes.
|
||||
CREATE TABLE IF NOT EXISTS print_runs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Where the print came from: 'web' | 'telegram' | 'mcp' | 'api'
|
||||
origin text NOT NULL DEFAULT 'web',
|
||||
-- Human-readable short form for the list ("13 × 18 cm ×1, 35 × 45 mm ×8").
|
||||
summary text,
|
||||
paper text, -- "A4 · 210 × 297 mm"
|
||||
pages integer NOT NULL DEFAULT 1,
|
||||
pieces integer NOT NULL DEFAULT 1,
|
||||
bytes integer, -- size of the generated PDF, informational only
|
||||
-- The full request that the sheet can be regenerated from.
|
||||
-- Deliberately the request body and not a bespoke format: what is stored
|
||||
-- here can be sent back to /api/print/sheet unchanged.
|
||||
config jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC);
|
||||
|
||||
-- Which library images went into a print. Only for images from the
|
||||
-- library — individually uploaded files have no durable identifier and are
|
||||
-- gone anyway once the sources are cleaned up.
|
||||
CREATE TABLE IF NOT EXISTS print_run_items (
|
||||
run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE,
|
||||
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (run_id, item_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id);
|
||||
Reference in new issue
Block a user