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,7 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
# Do NOT ignore migrations/ — they are read at runtime, on every boot.
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# Klarbild — configuration
|
||||||
|
#
|
||||||
|
# Copy to .env and fill in. Never commit the real file.
|
||||||
|
# Everything below the first block is optional: Klarbild starts, and the print
|
||||||
|
# studio works, with nothing but DATABASE_URL, SESSION_SECRET and ENCRYPTION_KEY.
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. The minimum — this is all a print-only instance needs
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DATABASE_URL=postgres://klarbild:CHANGEME@db:5432/klarbild
|
||||||
|
|
||||||
|
# Signs the session cookie. Any long random string.
|
||||||
|
# openssl rand -base64 48
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Encrypts the credentials Klarbild stores in the database (SMTP password,
|
||||||
|
# delivery password, API keys). Exactly 32 bytes as 64 hex characters.
|
||||||
|
# openssl rand -hex 32
|
||||||
|
# Losing this means losing those stored credentials — they cannot be recovered,
|
||||||
|
# only re-entered.
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Where finished files live. `fs` writes to a volume and needs nothing else;
|
||||||
|
# `s3` talks to MinIO or any S3-compatible store (see section 7).
|
||||||
|
STORAGE_DRIVER=fs
|
||||||
|
STORAGE_DIR=/data
|
||||||
|
|
||||||
|
# Which modules are on. `print` is always on, with or without this line.
|
||||||
|
# print the print studio alone (default)
|
||||||
|
# print,ai,share add image generation and share links
|
||||||
|
# all everything
|
||||||
|
# See src/lib/modules.ts for the full list: print, ai, delivery, mirror,
|
||||||
|
# share, mail, telegram.
|
||||||
|
KLARBILD_MODULES=print
|
||||||
|
|
||||||
|
# Interface language: en (default) or de. Visitors can switch with ?lang=,
|
||||||
|
# which sets a cookie; this is only what they get before they choose.
|
||||||
|
KLARBILD_LOCALE=en
|
||||||
|
|
||||||
|
# Timezone for the timestamp in generated file names.
|
||||||
|
KLARBILD_TZ=Europe/Berlin
|
||||||
|
|
||||||
|
PORT=4321
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. First start
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The first boot creates exactly one administrator. Set these and it uses them;
|
||||||
|
# leave them empty and Klarbild generates a password and prints it once to the
|
||||||
|
# server log. There are no other built-in accounts.
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. Public address
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Needed for share links and for the Telegram webhook. Behind a reverse proxy
|
||||||
|
# this is the address the browser sees, not the container port.
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL=http://localhost:4321
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 4. Module: ai — image generation
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Klarbild calls image models through OpenRouter. This costs money per image.
|
||||||
|
# The key can also be entered in the admin area; the database value wins.
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Module: mail
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Klarbild sends through your own mailbox rather than a delivery service, so
|
||||||
|
# SPF and DKIM are already right and no third party reads along. These values
|
||||||
|
# apply until something is entered under Admin → Email, which then takes over.
|
||||||
|
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 6. Module: delivery — push finished files to a server you already have
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The target itself (host, protocol, user, password) is configured in the admin
|
||||||
|
# area, because the password belongs in the encrypted settings row, not here.
|
||||||
|
# This is only the folder used when nothing more specific is set.
|
||||||
|
|
||||||
|
DELIVERY_DEFAULT_FOLDER=klarbild
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 7. Module: telegram
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# A bot token from @BotFather. The webhook registers itself after deploy and
|
||||||
|
# needs PUBLIC_BASE_URL to be reachable from the internet.
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 8. S3 / MinIO — only when STORAGE_DRIVER=s3
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=klarbild
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 9. Optional
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# A webhook that gets a POST when a job finishes — for n8n and the like.
|
||||||
|
|
||||||
|
N8N_WEBHOOK_URL=
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# `npm ci` needs the lockfile to match package.json exactly, which is the
|
||||||
|
# point: a pull request that changes a dependency has to say so.
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Secret scan
|
||||||
|
run: node scripts/secret-scan.mjs
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
*.log
|
||||||
|
uploads/
|
||||||
|
data/
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0 — first public release
|
||||||
|
|
||||||
|
The first open-source release of Klarbild. Everything before this lived in a
|
||||||
|
private repository as a personal tool.
|
||||||
|
|
||||||
|
**New in the public version**
|
||||||
|
|
||||||
|
- **Optional modules.** `KLARBILD_MODULES` decides what an instance is. The
|
||||||
|
default is the print studio alone — no image model, no object store, no bot
|
||||||
|
token, no account anywhere. See `src/lib/modules.ts`.
|
||||||
|
- **English interface**, with German as a full second language. The English
|
||||||
|
sentence is the translation key; 1,038 strings.
|
||||||
|
- **First-run setup.** No built-in accounts. The first boot creates one
|
||||||
|
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
|
||||||
|
and prints it once to the log.
|
||||||
|
- **Two compose files.** `docker-compose.yaml` is the print-only stack (app plus
|
||||||
|
Postgres, files on a volume); `docker-compose.full.yaml` adds MinIO and every
|
||||||
|
module.
|
||||||
|
- **Delivery is generic.** The delivery and backup targets are plain SFTP/FTPS
|
||||||
|
endpoints you configure, switched off by default.
|
||||||
|
- English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`,
|
||||||
|
`/tour`, `/password`.
|
||||||
|
- AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`.
|
||||||
|
|
||||||
|
**Carried over from the private tool**
|
||||||
|
|
||||||
|
The print engine as it stands: 86 image sizes in 10 groups, 19 paper formats
|
||||||
|
including A3+ and photo paper, corner marks and continuous lines, bleed 0–10 mm,
|
||||||
|
crop or letterbox per picture, oversize as fit / overflow / tile with glue flaps
|
||||||
|
and page numbers, captions in six positions, duplicate cells, presets, and a run
|
||||||
|
history that stores the instructions rather than the PDF. Plus the optional
|
||||||
|
modules: image generation, delivery, backup mirror, share links, mail, Telegram,
|
||||||
|
and an MCP server for assistants.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for looking. Klarbild is a small project with one maintainer, so the most
|
||||||
|
useful contributions are the ones that arrive ready to merge: a clear problem, a
|
||||||
|
minimal change, and a test where a test is possible.
|
||||||
|
|
||||||
|
## Getting it running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # DATABASE_URL, SESSION_SECRET, ENCRYPTION_KEY
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a PostgreSQL to point `DATABASE_URL` at; `docker compose up db` gives you
|
||||||
|
one. Migrations run themselves on the first request.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 124 tests, no database needed
|
||||||
|
npx tsc --noEmit # must be clean
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `npm test` and `tsc` run in CI and both must be green.
|
||||||
|
|
||||||
|
## How the code is laid out
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/print*.ts the print engine — pure arithmetic, heavily tested
|
||||||
|
src/lib/paper.ts 86 image sizes, 19 paper formats, the size parser
|
||||||
|
src/lib/modules.ts which optional modules are switched on
|
||||||
|
src/lib/i18n.ts translation; the English sentence is the key
|
||||||
|
src/components/ React islands, one per screen
|
||||||
|
src/pages/ Astro pages and the API under src/pages/api/
|
||||||
|
migrations/ numbered SQL, forward-only, idempotent, run on boot
|
||||||
|
tests/ node:test, no framework
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule that matters most: **the browser preview and the server-side PDF use the
|
||||||
|
same layout code.** If you change how a sheet is laid out, change it in
|
||||||
|
`src/lib/printlayout.ts` and both follow. Anything that recomputes geometry in a
|
||||||
|
component is a bug waiting to happen.
|
||||||
|
|
||||||
|
## House style
|
||||||
|
|
||||||
|
- Comments explain **why**, and carry the number that justifies the decision.
|
||||||
|
"Measured: 440 MB of images peaked at 196 MB of memory" is worth more than
|
||||||
|
"stream the files". Please keep that habit; it is most of what makes this
|
||||||
|
codebase navigable.
|
||||||
|
- No new dependency without a reason in the pull request. There are 20 runtime
|
||||||
|
dependencies and that is already plenty.
|
||||||
|
- Two-space indent, no semicolon crusade, no reformatting of files you did not
|
||||||
|
otherwise touch.
|
||||||
|
- Metric units. English decimal point in English strings.
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
The English string in the source **is** the key. To add a language:
|
||||||
|
|
||||||
|
1. Copy `src/locales/de.json` to `src/locales/<code>.json` and translate the values.
|
||||||
|
2. Add the code to `LOCALES` and `LOCALE_NAMES` in `src/lib/i18n.ts`, and to
|
||||||
|
`DICTS` in `src/lib/i18n.server.ts`.
|
||||||
|
|
||||||
|
A missing key falls back to English, so a partial translation is still useful. Never
|
||||||
|
build a sentence by concatenation — use a named placeholder: `t('{done} of {total}
|
||||||
|
done', { done, total })`.
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
Optional features live behind `src/lib/modules.ts`. A new one needs: an entry in
|
||||||
|
`MODULE_IDS` and `MODULE_INFO`, a `has('yours')` guard on its navigation entry and
|
||||||
|
its settings card, and `if (!has('yours')) return moduleOff('yours')` at the top of
|
||||||
|
every API route it owns. The test is simple — with the module off, the instance
|
||||||
|
should not hint that the feature exists.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
One change per pull request. Say what breaks if it is wrong. If you found a bug you
|
||||||
|
are not fixing, an issue with a reproduction is genuinely useful on its own.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Build tools for the native modules (argon2). sharp ships a prebuilt libvips.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4321)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
|
||||||
|
Copyright (C) 2026 Till Heidrich
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Klarbild
|
||||||
|
|
||||||
|
**Print pictures at the size you actually asked for.**
|
||||||
|
|
||||||
|
Your printer driver has a checkbox called "fit to page". Tick it and a 35 × 45 mm
|
||||||
|
passport photo comes out 34 × 44. Untick it and you are guessing. Photo shops have
|
||||||
|
software for this; the rest of us have a ruler and a bad afternoon.
|
||||||
|
|
||||||
|
Klarbild is that software, self-hosted. Give it an image and a physical size, and
|
||||||
|
it produces a PDF that measures exactly that on paper — with crop marks, with
|
||||||
|
bleed if you want it, with as many pictures on one sheet as fit, and with the
|
||||||
|
oversize cases handled rather than silently scaled.
|
||||||
|
|
||||||
|
It also does AI image generation, delivery to a server, share links, mail and a
|
||||||
|
Telegram bot. **All of that is optional.** The default install is the print studio,
|
||||||
|
a database, and nothing else — no API key, no model, no object store, no account
|
||||||
|
anywhere.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/tillheidrich/klarbild.git && cd klarbild
|
||||||
|
cp .env.example .env # fill in three secrets, the file says which
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs app # your admin password is printed here, once
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the print studio does
|
||||||
|
|
||||||
|
**Exact physical sizes.** 86 sizes in 10 groups — passport photo, small prints,
|
||||||
|
photo formats, US inches, instant-camera, poster and frame sizes, squares, DIN,
|
||||||
|
cards, plain aspect ratios — or type your own (`25x35`, `13x18`, `10x7,5`). The
|
||||||
|
output is a PDF laid out in millimetres, not a scaled bitmap, so 30 × 40 cm is
|
||||||
|
30 × 40 cm.
|
||||||
|
|
||||||
|
**Sheet layout.** Put several pictures on one sheet at 100 % with crop marks:
|
||||||
|
corner marks (0.25 pt, outside the trim) or continuous lines. Equal sizes snap to
|
||||||
|
an exact grid; mixed sizes go through a MaxRects packer. Bleed 0–10 mm. 19 paper
|
||||||
|
formats from A6 to A2 — including A3+ and photo paper 9 × 13 to 20 × 30, Letter
|
||||||
|
and Legal — or a custom sheet.
|
||||||
|
|
||||||
|
**Pictures bigger than the sheet, handled three ways.** A 20 × 30 cm print on A4
|
||||||
|
is not an error message. Choose **fit** (it tells you it will not fit), **overflow**
|
||||||
|
(centred across the whole sheet, and it quantifies the loss before you print —
|
||||||
|
"3 mm off each side"), or **tile** (poster print across several sheets, with a
|
||||||
|
dashed glue flap and a page number on each).
|
||||||
|
|
||||||
|
**Crop or letterbox, never distort.** When a picture does not match its target
|
||||||
|
ratio you decide per picture: crop it (with an interactive zoom-and-pan preview)
|
||||||
|
or leave a border in a colour you pick. Nothing is ever stretched.
|
||||||
|
|
||||||
|
**Captions, duplicates, presets.** A caption in one of six positions, automatic or
|
||||||
|
your own text — and it disappears by itself when there is no room at that edge.
|
||||||
|
The same picture can appear twice on one sheet at two different sizes. Presets for
|
||||||
|
the layouts you repeat; a history of the last 100 runs per account that stores the
|
||||||
|
*instructions*, never the PDF, so "print that again" costs 1–3 KB instead of 80 MB.
|
||||||
|
|
||||||
|
**The browser preview and the server PDF share one engine.** `printlayout.ts` is
|
||||||
|
pure arithmetic with its own tests. What you see is what gets rendered — the page
|
||||||
|
of the PDF above measures 210.00 × 297.00 mm, because that is what A4 is.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/print-de.png" width="49%" alt="The same screen in German" />
|
||||||
|
<img src="docs/screenshots/help-en.png" width="49%" alt="The built-in manual" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## What else is in the box, if you switch it on
|
||||||
|
|
||||||
|
| Module | What it adds | What it needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `print` | everything above | nothing — always on |
|
||||||
|
| `ai` | clean up screenshots, convert, combine, generate | an OpenRouter key (paid per image) |
|
||||||
|
| `delivery` | push finished files to an SFTP/FTPS server you already have | that server |
|
||||||
|
| `mirror` | a second copy of every result, sorted by month | a second target |
|
||||||
|
| `share` | public links: expiring, revocable, optional passphrase, ZIP download | a public URL |
|
||||||
|
| `mail` | password reset, job notices, sending share links | your own SMTP mailbox |
|
||||||
|
| `telegram` | the whole tool from a chat window | a bot token |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KLARBILD_MODULES=print # the default
|
||||||
|
KLARBILD_MODULES=print,ai,share # pick what you want
|
||||||
|
KLARBILD_MODULES=all # everything
|
||||||
|
```
|
||||||
|
|
||||||
|
A module that is off is off: no navigation entry, no settings card, and its API
|
||||||
|
routes answer 404 rather than 403 — a disabled instance does not advertise what it
|
||||||
|
could have done.
|
||||||
|
|
||||||
|
## Why self-hosted matters here
|
||||||
|
|
||||||
|
The pictures are family photos, passport photos, ID pictures and client work.
|
||||||
|
Klarbild keeps them on your machine. The print studio never makes an outbound
|
||||||
|
request at all — it is arithmetic, `sharp` and `pdf-lib`. Even with the AI module
|
||||||
|
on, only what you send to the model leaves the building, and the API key is yours.
|
||||||
|
|
||||||
|
Share links are built to be careful rather than convenient: they expire (30 days by
|
||||||
|
default), they can be revoked, they can carry a passphrase, downloads can be
|
||||||
|
switched off, and they are `noindex`. Access hangs on the link, not on the image, so
|
||||||
|
guessing an image id gets you a 404. The view counter records *when* and *with what*,
|
||||||
|
never *who*: the IP is hashed with a server secret **and** the link id, one row per
|
||||||
|
visitor per hour, deleted after 90 days.
|
||||||
|
|
||||||
|
## Stack and shape
|
||||||
|
|
||||||
|
Astro 5 (SSR, Node standalone) with React islands · PostgreSQL · `sharp` ·
|
||||||
|
`pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by
|
||||||
|
default; S3/MinIO is available but not required.
|
||||||
|
|
||||||
|
About 13,600 lines across 116 files, 124 tests, 16 forward-only idempotent
|
||||||
|
migrations that run themselves on boot. English and German interface, 1,038
|
||||||
|
translated strings; the English sentence is the translation key, so there is no key
|
||||||
|
file to keep in sync.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Docker (recommended).** `docker-compose.yaml` is the print-only stack: the app
|
||||||
|
and Postgres, files on a volume. `docker-compose.full.yaml` adds MinIO and turns
|
||||||
|
every module on. Both read their secrets from `.env` and refuse to start rather than
|
||||||
|
boot with a default password.
|
||||||
|
|
||||||
|
**From source.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations and first-run setup happen on the first request. There are no built-in
|
||||||
|
accounts: the first boot creates one administrator from `ADMIN_USERNAME` /
|
||||||
|
`ADMIN_PASSWORD`, or generates a password and prints it to the log once.
|
||||||
|
|
||||||
|
**Behind a reverse proxy**, set `PUBLIC_BASE_URL` to the address the browser sees.
|
||||||
|
Coolify users: both compose files carry a commented `SERVICE_FQDN_APP_4321` line.
|
||||||
|
|
||||||
|
## For agents and scripts
|
||||||
|
|
||||||
|
`GET /llms.txt` describes the whole API in a form a model can read. `/api/*` accepts
|
||||||
|
a `Bearer` token from the admin area. `mcp/` contains an MCP server, so an assistant
|
||||||
|
can lay out and fetch a print sheet directly — `exact_size` and `print_sheet` are
|
||||||
|
the two tools worth knowing.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||||
|
how the code is laid out and what the house style is. Security reports go to the
|
||||||
|
address in [SECURITY.md](SECURITY.md), not to the issue tracker.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
[GNU AGPL-3.0](LICENSE). Use it, change it, run it for yourself or for others — if
|
||||||
|
you run a modified version as a service, the people using it get your changes too.
|
||||||
|
|
||||||
|
Klarbild grew out of a private tool for getting family photos onto paper at the
|
||||||
|
right size. It is maintained by [Till Heidrich](https://github.com/tillheidrich).
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please do **not** open a public issue. Mail **security@heidrich-digital.de** with
|
||||||
|
what you found and how to reproduce it. You will get an acknowledgement within a
|
||||||
|
few days. There is no bounty programme — this is a one-person project — but
|
||||||
|
credit in the release notes is yours if you want it.
|
||||||
|
|
||||||
|
## What Klarbild already does
|
||||||
|
|
||||||
|
- Passwords are hashed with argon2. Stored credentials (SMTP, delivery targets,
|
||||||
|
API keys) are encrypted with AES-256-GCM using `ENCRYPTION_KEY`, never written
|
||||||
|
in plain text.
|
||||||
|
- Sessions are a signed cookie **plus** a server-side check. Changing a password
|
||||||
|
or a role invalidates every open session; the role comes from the database, not
|
||||||
|
from the cookie.
|
||||||
|
- Content-Security-Policy with per-script hashes rather than `'unsafe-inline'`,
|
||||||
|
set at build time. `style-src` keeps `'unsafe-inline'` because React writes
|
||||||
|
styles as element attributes, which cannot be hashed — a narrower class of
|
||||||
|
problem than script injection, which stays closed.
|
||||||
|
- Remote paths are validated immediately before writing, not somewhere upstream:
|
||||||
|
`posixpath.join('/customers/acme', '../../../etc/cron.d')` is `/etc/cron.d`, and
|
||||||
|
the upload would have created the directory first. See `safeSegment` in
|
||||||
|
`src/lib/remotetarget.ts` and its tests.
|
||||||
|
- Share links: expiry, revocation, optional argon2 passphrase with a rate limit,
|
||||||
|
`noindex`, and access bound to the link rather than to the image. Visit logging
|
||||||
|
hashes the IP with a server secret and the link id, keeps one row per visitor
|
||||||
|
per hour, and deletes after 90 days.
|
||||||
|
- A module that is switched off returns 404, not 403.
|
||||||
|
|
||||||
|
## What it does not do
|
||||||
|
|
||||||
|
- There is no multi-tenant isolation. Every account on an instance is on the same
|
||||||
|
instance; roles are `user` and `admin` and that is the whole model.
|
||||||
|
- Klarbild expects to run behind a reverse proxy that terminates TLS. It does not
|
||||||
|
do TLS itself.
|
||||||
|
- `ENCRYPTION_KEY` has no rotation path yet. Losing it means re-entering the
|
||||||
|
stored credentials.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
The latest release. There is no long-term support branch.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
|
// Klarbild runs as a Node server (SSR) with an in-process queue.
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
integrations: [react()],
|
||||||
|
// CSRF: handled by our own SameSite=Lax session cookie. Astro's checkOrigin
|
||||||
|
// breaks multipart uploads behind a reverse proxy, because the proxy speaks
|
||||||
|
// http to the app while the browser spoke https and the origins no longer match.
|
||||||
|
security: { checkOrigin: false },
|
||||||
|
// Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes
|
||||||
|
// the hash of every inline script and style it emits, at build time. The remaining
|
||||||
|
// directives are set by the middleware (src/middleware.ts).
|
||||||
|
experimental: {
|
||||||
|
csp: {
|
||||||
|
directives: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { host: true, port: Number(process.env.PORT) || 4321 },
|
||||||
|
vite: {
|
||||||
|
ssr: {
|
||||||
|
// Do not bundle native modules
|
||||||
|
external: ['sharp', 'argon2', 'pg', 'pg-boss', 'ssh2-sftp-client', 'basic-ftp', 'heic-convert'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Klarbild — everything switched on: print studio, image generation, delivery,
|
||||||
|
# backup mirror, share links, mail, Telegram bot, and MinIO as the object store.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.full.yaml up -d
|
||||||
|
#
|
||||||
|
# Only take this one if you actually want the extra parts. The modules you leave
|
||||||
|
# unconfigured stay dark rather than half-working, but you are still running two
|
||||||
|
# more containers than the default stack needs.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "4321"
|
||||||
|
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
||||||
|
|
||||||
|
STORAGE_DRIVER: "s3"
|
||||||
|
S3_ENDPOINT: "http://minio:9000"
|
||||||
|
S3_REGION: "us-east-1"
|
||||||
|
S3_BUCKET: "klarbild"
|
||||||
|
S3_ACCESS_KEY: "${S3_ACCESS_KEY:?set S3_ACCESS_KEY in .env}"
|
||||||
|
S3_SECRET_KEY: "${S3_SECRET_KEY:?set S3_SECRET_KEY in .env}"
|
||||||
|
S3_FORCE_PATH_STYLE: "true"
|
||||||
|
|
||||||
|
SESSION_SECRET: "${SESSION_SECRET:?set SESSION_SECRET in .env}"
|
||||||
|
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env}"
|
||||||
|
|
||||||
|
KLARBILD_MODULES: "${KLARBILD_MODULES:-all}"
|
||||||
|
KLARBILD_LOCALE: "${KLARBILD_LOCALE:-en}"
|
||||||
|
KLARBILD_TZ: "${KLARBILD_TZ:-Europe/Berlin}"
|
||||||
|
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:?share links and the Telegram webhook need a public address}"
|
||||||
|
|
||||||
|
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
||||||
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
|
||||||
|
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
|
||||||
|
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-}"
|
||||||
|
|
||||||
|
# The database wins over these; they only apply until something is entered
|
||||||
|
# under Admin → Email, so a fresh install can send mail straight away.
|
||||||
|
SMTP_HOST: "${SMTP_HOST:-}"
|
||||||
|
SMTP_PORT: "${SMTP_PORT:-587}"
|
||||||
|
SMTP_SECURE: "${SMTP_SECURE:-false}"
|
||||||
|
SMTP_USER: "${SMTP_USER:-}"
|
||||||
|
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
|
||||||
|
SMTP_FROM: "${SMTP_FROM:-}"
|
||||||
|
|
||||||
|
# Coolify users: uncomment and drop the `ports:` block below.
|
||||||
|
# SERVICE_FQDN_APP_4321: /
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${PORT:-4321}:4321"
|
||||||
|
# See docker-compose.yaml for why this limit is here.
|
||||||
|
mem_limit: 3g
|
||||||
|
mem_reservation: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: klarbild
|
||||||
|
POSTGRES_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}"
|
||||||
|
POSTGRES_DB: klarbild
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U klarbild"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: "${S3_ACCESS_KEY:?set S3_ACCESS_KEY in .env}"
|
||||||
|
MINIO_ROOT_PASSWORD: "${S3_SECRET_KEY:?set S3_SECRET_KEY in .env}"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
minio-data:
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Klarbild — the default stack: the print studio and a database. That is all.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # fill in the three secrets at the top
|
||||||
|
# docker compose up -d
|
||||||
|
# docker compose logs app # the first-start password is printed once, here
|
||||||
|
#
|
||||||
|
# No object store, no image model, no bot token. If you want those, use
|
||||||
|
# docker-compose.full.yaml instead, or set KLARBILD_MODULES yourself.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "4321"
|
||||||
|
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
||||||
|
|
||||||
|
# Files on a volume. Nothing else to run, nothing else to back up.
|
||||||
|
STORAGE_DRIVER: "fs"
|
||||||
|
STORAGE_DIR: "/data"
|
||||||
|
|
||||||
|
# openssl rand -base64 48
|
||||||
|
SESSION_SECRET: "${SESSION_SECRET:?set SESSION_SECRET in .env}"
|
||||||
|
# openssl rand -hex 32
|
||||||
|
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env}"
|
||||||
|
|
||||||
|
KLARBILD_MODULES: "${KLARBILD_MODULES:-print}"
|
||||||
|
KLARBILD_LOCALE: "${KLARBILD_LOCALE:-en}"
|
||||||
|
KLARBILD_TZ: "${KLARBILD_TZ:-Europe/Berlin}"
|
||||||
|
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-http://localhost:4321}"
|
||||||
|
|
||||||
|
# Leave empty and the first boot generates a password and logs it once.
|
||||||
|
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
||||||
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||||
|
|
||||||
|
# Coolify users: uncomment the next line and drop the `ports:` block
|
||||||
|
# below — Coolify's proxy wants the service, not a published port.
|
||||||
|
# SERVICE_FQDN_APP_4321: /
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${PORT:-4321}:4321"
|
||||||
|
volumes:
|
||||||
|
- files:/data
|
||||||
|
# Memory ceiling: sharp can spike hard on a large image. Without a limit one
|
||||||
|
# outlier takes the whole container with it; with a limit only the Node
|
||||||
|
# process dies, and the healthcheck brings it back.
|
||||||
|
mem_limit: 3g
|
||||||
|
mem_reservation: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: klarbild
|
||||||
|
POSTGRES_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}"
|
||||||
|
POSTGRES_DB: klarbild
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U klarbild"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
files:
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
# Klarbild MCP server
|
||||||
|
|
||||||
|
Lets an AI assistant (e.g. Claude) hand images to Klarbild and have it process
|
||||||
|
them — things like "take the images from folder XY" or "process the photos
|
||||||
|
someone sent me over iMessage". The assistant fetches the image files (with its
|
||||||
|
own tools) and then calls the Klarbild tools.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Generate a token in Klarbild: **Admin → "Automation / MCP access" → Generate token**.
|
||||||
|
2. Install the dependency: `npm i @modelcontextprotocol/sdk`
|
||||||
|
3. Start the server (or register it in your MCP client):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"klarbild": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/mcp/klarbild-mcp.mjs"],
|
||||||
|
"env": {
|
||||||
|
"KLARBILD_URL": "https://klarbild.example.com",
|
||||||
|
"KLARBILD_TOKEN": "klb_…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- **list_recipes** — list the presets/recipes available.
|
||||||
|
- **process_images** — upload and process images (local paths or `data:` URLs).
|
||||||
|
Give either `recipeId` (a preset) or `mode` (`each`/`compose`/`generate`) + options:
|
||||||
|
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
|
||||||
|
(fixed, like `30x40`/`theframe`, **or custom**, `25x35` / `sticker5`), `output_ext`
|
||||||
|
(`png`/`jpg`, omit = global default), `orientation`, `crop_mode`, `contour_mm`,
|
||||||
|
`delivery_folder`, `delivery`. Returns the job ID.
|
||||||
|
- **job_status** — query the status of a job (including finish time and error messages).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Klarbild has optional modules, switched on with the `KLARBILD_MODULES` environment
|
||||||
|
variable (see `src/lib/modules.ts`). A tool that belongs to a disabled module
|
||||||
|
(for example `process_images` when the `ai` module is off, since it can start an
|
||||||
|
AI generation) returns a 404 from the Klarbild API — the MCP server surfaces that
|
||||||
|
as a tool error, it does not hide or special-case it.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The token grants full programmatic access to upload/processing (not to the admin
|
||||||
|
area). Treat it like a password; if you suspect it has leaked, generate a new one
|
||||||
|
(which invalidates the old one).
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Klarbild MCP server — lets an AI assistant (e.g. Claude) hand local images, or
|
||||||
|
// images obtained some other way (iMessage, a given folder, …), over to Klarbild
|
||||||
|
// for processing. The assistant fetches the image files; this server uploads
|
||||||
|
// them and creates a job.
|
||||||
|
//
|
||||||
|
// Setup:
|
||||||
|
// npm i @modelcontextprotocol/sdk
|
||||||
|
// KLARBILD_URL=https://klarbild.example.com \
|
||||||
|
// KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs
|
||||||
|
// The token is generated in Klarbild under Admin → "Automation / MCP access".
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
|
||||||
|
const TOKEN = process.env.KLARBILD_TOKEN || '';
|
||||||
|
if (!BASE || !TOKEN) { console.error('KLARBILD_URL and KLARBILD_TOKEN must be set.'); process.exit(1); }
|
||||||
|
|
||||||
|
const authHeaders = { Authorization: `Bearer ${TOKEN}` };
|
||||||
|
const api = (path) => `${BASE}${path}`;
|
||||||
|
|
||||||
|
async function uploadFile(pathOrBase64, name) {
|
||||||
|
let buf, filename = name;
|
||||||
|
if (pathOrBase64.startsWith('data:')) {
|
||||||
|
buf = Buffer.from(pathOrBase64.split(',')[1], 'base64');
|
||||||
|
filename = name || 'image.png';
|
||||||
|
} else {
|
||||||
|
buf = await readFile(pathOrBase64);
|
||||||
|
filename = name || basename(pathOrBase64);
|
||||||
|
}
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('files', new Blob([buf]), filename);
|
||||||
|
const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
const info = (j.files || [])[0];
|
||||||
|
if (!info?.source_path) throw new Error(info?.error || 'Upload failed');
|
||||||
|
return { source_path: info.source_path, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ name: 'list_recipes', description: 'List the presets/recipes available in Klarbild.',
|
||||||
|
inputSchema: { type: 'object', properties: {} } },
|
||||||
|
{ name: 'process_images',
|
||||||
|
description: 'Hand images to Klarbild and process them. Files as local paths OR data: URLs. Give either recipeId OR mode+options.',
|
||||||
|
inputSchema: { type: 'object', properties: {
|
||||||
|
files: { type: 'array', items: { type: 'string' }, description: 'Local paths or data: URLs' },
|
||||||
|
recipeId: { type: 'string', description: 'Optional: preset ID (from list_recipes) — overrides the individual options' },
|
||||||
|
mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'If no recipeId is given' },
|
||||||
|
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
|
||||||
|
description: 'Only for mode=each. Default: ["format"] if output_format is set.' },
|
||||||
|
prompt_text: { type: 'string', description: 'Description for compose/generate' },
|
||||||
|
output_format: { type: 'string', description: 'Fixed: 30x40, A4, theframe, portrait916, keep … OR custom: "25x35" (=25×35 cm) or "sticker5" (=5×5 cm).' },
|
||||||
|
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'File format. Omit = global default setting. JPG only without transparency.' },
|
||||||
|
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
|
||||||
|
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
|
||||||
|
contour_mm: { type: 'number', description: 'Sticker border in mm (only with tasks=cutout+contour).' },
|
||||||
|
delivery_folder: { type: 'string', description: 'Target folder/subfolder on the delivery target (with delivery remote/both).' },
|
||||||
|
delivery: { type: 'string', enum: ['library', 'remote', 'both'] },
|
||||||
|
}, required: ['files'] } },
|
||||||
|
{ name: 'job_status', description: 'Query the status of a job (including finish time and error messages).',
|
||||||
|
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
|
||||||
|
|
||||||
|
{ name: 'exact_size',
|
||||||
|
description: 'WITHOUT AI: bring an image to an exact physical size (centered crop at the target ratio, dpi metadata). Result is saved locally.',
|
||||||
|
inputSchema: { type: 'object', properties: {
|
||||||
|
file: { type: 'string', description: 'Local path or data: URL' },
|
||||||
|
size: { type: 'string', description: 'Size: "12x15" (cm), "35x45mm", "5" (=5×5 cm) or "4:3/15"' },
|
||||||
|
landscape: { type: 'boolean', description: 'Landscape instead of portrait' },
|
||||||
|
dpi: { type: 'number', description: 'Default 300' },
|
||||||
|
ext: { type: 'string', enum: ['jpg', 'png'] },
|
||||||
|
out: { type: 'string', description: 'Target path for the file (default: ./klarbild-<size>.<ext>)' },
|
||||||
|
}, required: ['file', 'size'] } },
|
||||||
|
|
||||||
|
{ name: 'print_sheet',
|
||||||
|
description: 'WITHOUT AI: place several images at 100% size with crop marks onto a print sheet and save as a PDF. For sets of passport photos, daycare photos, stickers.',
|
||||||
|
inputSchema: { type: 'object', properties: {
|
||||||
|
images: { type: 'array', description: 'Per entry: file + final size + count',
|
||||||
|
items: { type: 'object', properties: {
|
||||||
|
file: { type: 'string', description: 'Local path or data: URL' },
|
||||||
|
size: { type: 'string', description: 'Final size, e.g. "35x45mm", "9x13", "12x15"' },
|
||||||
|
count: { type: 'number', description: 'How many times on the sheet (default 1)' },
|
||||||
|
landscape: { type: 'boolean' },
|
||||||
|
allowRotate: { type: 'boolean', description: 'May be placed rotated (default true)' },
|
||||||
|
}, required: ['file', 'size'] } },
|
||||||
|
paper: { type: 'string', description: 'A4 (default), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — or a custom size like "32.9x48.3"' },
|
||||||
|
landscape: { type: 'boolean', description: 'Sheet landscape' },
|
||||||
|
marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Cut guides (default corner)' },
|
||||||
|
marginMm: { type: 'number', description: 'Margin to the paper edge, default 5' },
|
||||||
|
gapMm: { type: 'number', description: 'Gap between the images, defaults to match the marks' },
|
||||||
|
bleedMm: { type: 'number', description: 'Bleed per side, default 0' },
|
||||||
|
dpi: { type: 'number', description: 'Default 300' },
|
||||||
|
out: { type: 'string', description: 'Target path for the PDF (default: ./klarbild-sheet.pdf)' },
|
||||||
|
}, required: ['images'] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
|
||||||
|
|
||||||
|
server.setRequestHandler({ method: 'tools/call' }, async (req) => {
|
||||||
|
const { name, arguments: a = {} } = req.params;
|
||||||
|
try {
|
||||||
|
if (name === 'list_recipes') {
|
||||||
|
const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json();
|
||||||
|
const list = (j.recipes || []).map((r) => `${r.id} — ${r.name} (${r.mode || 'each'})`).join('\n');
|
||||||
|
return { content: [{ type: 'text', text: list || 'No presets.' }] };
|
||||||
|
}
|
||||||
|
if (name === 'process_images') {
|
||||||
|
const files = a.files || [];
|
||||||
|
const mode = a.mode || 'each';
|
||||||
|
const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f)));
|
||||||
|
const wantsFormat = a.output_format && a.output_format !== 'keep';
|
||||||
|
const tasks = Array.isArray(a.tasks) && a.tasks.length ? a.tasks : (wantsFormat ? ['format'] : []);
|
||||||
|
const body = a.recipeId
|
||||||
|
? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
|
||||||
|
: { mode, prompt_text: a.prompt_text, sources,
|
||||||
|
recipe: {
|
||||||
|
tasks: mode === 'each' ? tasks : (wantsFormat ? ['format'] : []),
|
||||||
|
output_format: a.output_format || 'keep',
|
||||||
|
output_ext: a.output_ext || null,
|
||||||
|
orientation: a.orientation || 'portrait',
|
||||||
|
crop_mode: a.crop_mode || 'crop',
|
||||||
|
contour_mm: a.contour_mm ?? null,
|
||||||
|
delivery_folder: a.delivery_folder || null,
|
||||||
|
delivery: a.delivery || 'library',
|
||||||
|
},
|
||||||
|
delivery: a.delivery || 'library' };
|
||||||
|
const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
|
||||||
|
if (!j.jobId) throw new Error(j.error || 'Job failed');
|
||||||
|
return { content: [{ type: 'text', text: `Job created: ${j.jobId}` }] };
|
||||||
|
}
|
||||||
|
if (name === 'exact_size') {
|
||||||
|
const up = await uploadFile(a.file);
|
||||||
|
const r = await fetch(api('/api/print/single'), {
|
||||||
|
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size,
|
||||||
|
landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const ext = a.ext === 'png' ? 'png' : 'jpg';
|
||||||
|
const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`);
|
||||||
|
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||||
|
const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi');
|
||||||
|
const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Source only supports about ${real} dpi.` : '';
|
||||||
|
return { content: [{ type: 'text', text: `Saved: ${out} (${px} px).${warn}` }] };
|
||||||
|
}
|
||||||
|
if (name === 'print_sheet') {
|
||||||
|
const imgs = a.images || [];
|
||||||
|
if (!imgs.length) throw new Error('No images given.');
|
||||||
|
const cells = [];
|
||||||
|
for (const [i, im] of imgs.entries()) {
|
||||||
|
const up = await uploadFile(im.file);
|
||||||
|
cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size,
|
||||||
|
count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false });
|
||||||
|
}
|
||||||
|
const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' };
|
||||||
|
const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5,
|
||||||
|
bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true,
|
||||||
|
marks: { mode: a.marks || 'corner' }, cells };
|
||||||
|
if (a.gapMm != null) body.gapMm = a.gapMm;
|
||||||
|
const r = await fetch(api('/api/print/sheet'), {
|
||||||
|
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const out = resolve(a.out || './klarbild-sheet.pdf');
|
||||||
|
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||||
|
return { content: [{ type: 'text', text:
|
||||||
|
`Saved: ${out} — ${r.headers.get('x-klarbild-pages')} sheet(s), ${r.headers.get('x-klarbild-per-sheet')} images on sheet 1. ` +
|
||||||
|
'When printing, choose "Actual size / 100%".' }] };
|
||||||
|
}
|
||||||
|
if (name === 'job_status') {
|
||||||
|
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
|
||||||
|
const job = j.job || j;
|
||||||
|
const errs = (j.items || []).map((it) => it.error_message).filter(Boolean);
|
||||||
|
const fin = job.finished_at ? ` · finished ${job.finished_at}` : '';
|
||||||
|
const errTxt = errs.length ? `\nErrors: ${errs.join('; ')}` : '';
|
||||||
|
return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} done${job.failed_count ? `, ${job.failed_count} failed` : ''}${fin}${errTxt}` }] };
|
||||||
|
}
|
||||||
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${e?.message || e}` }], isError: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
console.error('[klarbild-mcp] ready');
|
||||||
@@ -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);
|
||||||
Generated
+9266
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "klarbild",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Self-hosted print studio: put pictures on paper at exactly the size you asked for. Sheet layout with crop marks, tiling, 1:1 PDF. Optional AI, delivery, sharing and Telegram modules.",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"author": "Till Heidrich",
|
||||||
|
"homepage": "https://github.com/tillheidrich/klarbild",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tillheidrich/klarbild.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/tillheidrich/klarbild/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"print",
|
||||||
|
"pdf",
|
||||||
|
"photo",
|
||||||
|
"passport-photo",
|
||||||
|
"self-hosted",
|
||||||
|
"astro",
|
||||||
|
"crop-marks",
|
||||||
|
"poster",
|
||||||
|
"tiling",
|
||||||
|
"sheet-layout"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"start": "node ./dist/server/entry.mjs",
|
||||||
|
"astro": "astro",
|
||||||
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"scan": "node scripts/secret-scan.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.1.3",
|
||||||
|
"@astrojs/react": "^4.2.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||||
|
"@fontsource/space-grotesk": "^5.1.0",
|
||||||
|
"@fontsource/space-mono": "^5.1.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"argon2": "^0.41.1",
|
||||||
|
"astro": "^5.5.0",
|
||||||
|
"basic-ftp": "^5.0.5",
|
||||||
|
"grammy": "^1.30.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
|
"nodemailer": "^9.1.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"pg-boss": "^10.1.5",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"ssh2-sftp-client": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.5.1",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/ssh2-sftp-client": "^9.0.6",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* A small guard against committing a credential.
|
||||||
|
*
|
||||||
|
* This is not a replacement for gitleaks — run that too if you have it. It
|
||||||
|
* exists because the cheapest moment to catch a leaked key is before the push,
|
||||||
|
* and because a project that ships `.env.example` invites the mistake of
|
||||||
|
* shipping `.env` next to it.
|
||||||
|
*
|
||||||
|
* Exits non-zero on the first finding, printing the file and line.
|
||||||
|
*/
|
||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import { join, relative, extname } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = new URL('..', import.meta.url).pathname;
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.astro', 'coverage']);
|
||||||
|
const SKIP_FILES = new Set(['package-lock.json', 'LICENSE', 'secret-scan.mjs']);
|
||||||
|
const TEXT_EXT = new Set([
|
||||||
|
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.astro', '.json', '.sql', '.md',
|
||||||
|
'.yaml', '.yml', '.css', '.html', '.txt', '.example', '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
[/sk-[A-Za-z0-9]{20,}/, 'OpenAI-style API key'],
|
||||||
|
[/sk-or-v1-[A-Za-z0-9]{20,}/, 'OpenRouter API key'],
|
||||||
|
[/gh[pousr]_[A-Za-z0-9]{30,}/, 'GitHub token'],
|
||||||
|
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
|
||||||
|
[/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
|
||||||
|
[/-----BEGIN (?:RSA |OPENSSH |EC |PGP )?PRIVATE KEY-----/, 'private key'],
|
||||||
|
[/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/, 'Telegram bot token'],
|
||||||
|
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, 'JWT'],
|
||||||
|
// Assignments that look like a real value rather than a placeholder.
|
||||||
|
[/(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["'][^"'${}\s]{12,}["']/i,
|
||||||
|
'hardcoded credential'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lines that are allowed to match: documentation of the shape of a secret,
|
||||||
|
// and the example file, which is meant to show the names without the values.
|
||||||
|
const ALLOW = [
|
||||||
|
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand/i,
|
||||||
|
/^\s*(?:\/\/|#|--|\*)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
let findings = 0;
|
||||||
|
|
||||||
|
async function walk(dir) {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue;
|
||||||
|
await walk(join(dir, entry.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SKIP_FILES.has(entry.name)) continue;
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.name !== '.env.example' && !TEXT_EXT.has(extname(entry.name))) continue;
|
||||||
|
if (statSync(path).size > 2_000_000) continue;
|
||||||
|
scan(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(path) {
|
||||||
|
const rel = relative(ROOT, path);
|
||||||
|
const lines = readFileSync(path, 'utf8').split('\n');
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (ALLOW.some((a) => a.test(line))) return;
|
||||||
|
for (const [re, what] of RULES) {
|
||||||
|
if (re.test(line)) {
|
||||||
|
console.error(`${rel}:${i + 1} possible ${what}`);
|
||||||
|
console.error(` ${line.trim().slice(0, 120)}`);
|
||||||
|
findings++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(ROOT);
|
||||||
|
|
||||||
|
if (findings) {
|
||||||
|
console.error(`\n${findings} possible secret(s) found. Nothing was committed.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Secret scan clean.');
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your own account: store an email address and switch notifications on or off.
|
||||||
|
*
|
||||||
|
* The address is at the same time the way to reset a forgotten password — that
|
||||||
|
* is spelled out here as well, so nobody mistakes it for pure advertising mail.
|
||||||
|
*/
|
||||||
|
interface Me {
|
||||||
|
username: string | null; display_name: string | null;
|
||||||
|
email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoadError(false);
|
||||||
|
fetch('/api/me')
|
||||||
|
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
|
||||||
|
.then((m) => { setMe(m); setEmail(m.email || ''); })
|
||||||
|
// Without this branch it said "One moment …" here for ever.
|
||||||
|
.catch(() => setLoadError(true));
|
||||||
|
};
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
const say = (text: string, ok: boolean) => { setMsg({ text, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||||
|
|
||||||
|
const patch = async (body: unknown, success: string) => {
|
||||||
|
// Take on the new value right away, otherwise the checkbox jumps back to
|
||||||
|
// the old state on rerender and you click a second time by accident.
|
||||||
|
setMe((m) => (m ? { ...m, ...(body as object) } as Me : m));
|
||||||
|
setBusy(true);
|
||||||
|
const r = await fetch('/api/me', {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||||
|
setBusy(false);
|
||||||
|
if (r.error) { say(r.error, false); return false; }
|
||||||
|
say(success, true);
|
||||||
|
const m = await fetch('/api/me').then((x) => x.json()).catch(() => null);
|
||||||
|
if (m) setMe(m);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadError) return (
|
||||||
|
<div className="ac">
|
||||||
|
<section className="ac-card"><div className="ac-body">
|
||||||
|
<p className="ac-hint">{t('Your account could not be loaded just now — maybe the connection was gone for a moment.')}</p>
|
||||||
|
<button className="ac-btn strong" onClick={load}>{t('Try again')}</button>
|
||||||
|
</div></section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!me) return <p className="ac-hint">{t('One moment …')}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ac">
|
||||||
|
<section className="ac-card">
|
||||||
|
<div className="ac-head"><span className="mono-label">{t('Sign in')}</span></div>
|
||||||
|
<div className="ac-body">
|
||||||
|
<div className="ac-row">
|
||||||
|
<span className="ac-label">{t('Username')}</span>
|
||||||
|
<span className="ac-value">{me.username}</span>
|
||||||
|
</div>
|
||||||
|
{me.display_name && (
|
||||||
|
<div className="ac-row">
|
||||||
|
<span className="ac-label">{t('Shown as')}</span>
|
||||||
|
<span className="ac-value">{me.display_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ac-row">
|
||||||
|
<span className="ac-label">{t('Password')}</span>
|
||||||
|
<span className="ac-value"><a href="/password">{t('Set a new password')}</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ac-card">
|
||||||
|
<div className="ac-head"><span className="mono-label">{t('Email address')}</span></div>
|
||||||
|
<div className="ac-body">
|
||||||
|
<p className="ac-hint">
|
||||||
|
{t('This lets you have a new password sent to you if you have forgotten yours. Without an address on file that is not possible — then an admin has to step in.')}
|
||||||
|
</p>
|
||||||
|
<div className="ac-input">
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com" aria-label={t('Email address')} autoComplete="email" />
|
||||||
|
<button className="ac-btn strong" disabled={busy || email === (me.email || '')}
|
||||||
|
onClick={() => patch({ email }, email ? t('Address saved.') : t('Address removed.'))}>
|
||||||
|
{t('Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!me.mail_ready && (
|
||||||
|
<p className="ac-warn">
|
||||||
|
{t('No mail sending is set up on this server yet — the address is stored, but nothing arrives yet. An admin sets that up under Admin → Email.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ac-card">
|
||||||
|
<div className="ac-head"><span className="mono-label">{t('Notifications')}</span></div>
|
||||||
|
<div className="ac-body">
|
||||||
|
<label className="ac-toggle">
|
||||||
|
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email || busy}
|
||||||
|
onChange={(e) => patch({ notify_jobs: e.target.checked },
|
||||||
|
e.target.checked ? t('Notices switched on.') : t('Notices switched off.'))} />
|
||||||
|
<span>
|
||||||
|
<b>{t('Tell me when a job is finished')}</b>
|
||||||
|
<em>{t('One short mail per job — no newsletter, no advertising.')}
|
||||||
|
{!me.email && ' ' + t('For that you first need an email address (above).')}</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="ac-hint">
|
||||||
|
{t('Jobs from a private session deliberately never trigger a mail — the whole point is that they leave no trace.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{msg && <p className={`ac-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.text}</p>}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.ac{display:grid;gap:14px;max-width:620px;}
|
||||||
|
.ac-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||||
|
.ac-head{padding:13px 16px;border-bottom:1px solid var(--line);}
|
||||||
|
.ac-body{padding:16px;display:grid;gap:12px;}
|
||||||
|
.ac-row{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
|
||||||
|
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
|
||||||
|
.ac-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||||
|
.ac-label{color:var(--soft);font-size:.85rem;}
|
||||||
|
.ac-value{font-weight:500;overflow-wrap:anywhere;text-align:right;}
|
||||||
|
.ac-value a{color:var(--accent);}
|
||||||
|
.ac-hint{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
|
||||||
|
.ac-input{display:flex;gap:8px;}
|
||||||
|
.ac-input input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
|
||||||
|
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
|
||||||
|
.ac-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||||
|
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||||
|
.ac-btn.strong{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||||
|
.ac-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||||||
|
.ac-toggle{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
|
||||||
|
.ac-toggle input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||||
|
.ac-toggle input:disabled{cursor:not-allowed;}
|
||||||
|
.ac-toggle b{display:block;}
|
||||||
|
.ac-toggle em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
|
||||||
|
.ac-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||||
|
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||||
|
.ac-msg{margin:0;font-size:.9rem;}
|
||||||
|
.ac-msg.ok{color:var(--ok);}
|
||||||
|
.ac-msg.err{color:var(--err);}
|
||||||
|
@media(max-width:560px){
|
||||||
|
.ac-input{flex-direction:column;}
|
||||||
|
.ac-btn{width:100%;padding:13px;}
|
||||||
|
.ac-row{flex-direction:column;gap:3px;}
|
||||||
|
.ac-value{text-align:left;}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [s, setS] = useState<any>(null);
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
|
const [models, setModels] = useState<any[]>([]);
|
||||||
|
const [avail, setAvail] = useState<any[]>([]);
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [tgLinks, setTgLinks] = useState<any[]>([]);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const [orKey, setOrKey] = useState(''); const [dtPw, setDtPw] = useState(''); const [mirrorPw, setMirrorPw] = useState('');
|
||||||
|
const [storage, setStorage] = useState<any>(null);
|
||||||
|
const [rename, setRename] = useState<any>(null); // dry run / result of the rename
|
||||||
|
const [mailTo, setMailTo] = useState(''); // address for the test message
|
||||||
|
const [smtpPw, setSmtpPw] = useState(''); // new mailbox password (never prefilled)
|
||||||
|
const [targets, setTargets] = useState<any[]>([]);
|
||||||
|
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||||
|
const notify = (text: string) => { setToast(text); setTimeout(() => setToast(null), 2600); };
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [a, b, c, d, e, f, g] = await Promise.all([
|
||||||
|
fetch('/api/admin/settings').then((r) => r.json()),
|
||||||
|
fetch('/api/admin/stats').then((r) => r.json()),
|
||||||
|
fetch('/api/admin/models?available=1').then((r) => r.json()),
|
||||||
|
fetch('/api/admin/users').then((r) => r.json()),
|
||||||
|
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
|
||||||
|
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
|
||||||
|
fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })),
|
||||||
|
]);
|
||||||
|
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
|
||||||
|
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []);
|
||||||
|
}, []);
|
||||||
|
const addTarget = async () => {
|
||||||
|
if (!nt.name?.trim()) { notify(t('Name is missing.')); return; }
|
||||||
|
await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) });
|
||||||
|
setNt({ protocol: 'sftp' }); notify(t('Target created.')); load();
|
||||||
|
};
|
||||||
|
const delTarget = async (id: string) => { if (!confirm(t('Delete target?'))) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
|
||||||
|
const toggleBackup = async (target: any) => {
|
||||||
|
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: target.id, is_backup: !target.is_backup }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const toggleTargetSidecar = async (target: any) => {
|
||||||
|
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: target.id, metadata_sidecar: !target.metadata_sidecar }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const backupAllTargets = async () => {
|
||||||
|
if (!confirm(t('Back up all finished images to every backup target (mirror + marked targets)?'))) return;
|
||||||
|
notify(t('Backing up … (may take a while)'));
|
||||||
|
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
|
||||||
|
notify(r.items != null
|
||||||
|
? (r.failed
|
||||||
|
? t('{n} copies to backup targets, {failed} errors.', { n: r.mirrored, failed: r.failed })
|
||||||
|
: t('{n} copies to backup targets.', { n: r.mirrored }))
|
||||||
|
: (r.error || 'OK'));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const genToken = async () => {
|
||||||
|
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
|
||||||
|
notify(t('API token generated.')); load();
|
||||||
|
};
|
||||||
|
const testTarget = async (id: string) => {
|
||||||
|
notify(t('Testing target …'));
|
||||||
|
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
|
||||||
|
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||||
|
};
|
||||||
|
const fmtBytes = (n: number | null) => n == null ? '—'
|
||||||
|
: n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`;
|
||||||
|
|
||||||
|
const pairCode = async (userId: string) => {
|
||||||
|
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
|
||||||
|
if (r.code) alert(t('Pairing code: {code}\n\nValid for 15 minutes. Enter it in the Klarbild Telegram chat.', { code: r.code }));
|
||||||
|
};
|
||||||
|
const unlink = async (chat_id: number) => {
|
||||||
|
await fetch('/api/admin/telegram/links', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v }));
|
||||||
|
const saveSettings = async () => {
|
||||||
|
const body: any = {
|
||||||
|
delivery_host: s.delivery_host, delivery_protocol: s.delivery_protocol, delivery_port: s.delivery_port,
|
||||||
|
delivery_user: s.delivery_user, delivery_base_path: s.delivery_base_path, delivery_default_folder: s.delivery_default_folder,
|
||||||
|
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||||
|
output_ext: s.output_ext || 'png',
|
||||||
|
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||||
|
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||||
|
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar,
|
||||||
|
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||||
|
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||||
|
mirror_enabled: !!s.mirror_enabled, mirror_host: s.mirror_host, mirror_protocol: s.mirror_protocol, mirror_port: s.mirror_port,
|
||||||
|
mirror_user: s.mirror_user, mirror_base_path: s.mirror_base_path,
|
||||||
|
// Mail and sharing — without these lines the whole card was discarded
|
||||||
|
// silently and the interface still reported "Saved."
|
||||||
|
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure !== false,
|
||||||
|
smtp_user: s.smtp_user, smtp_from: s.smtp_from,
|
||||||
|
share_default_days: s.share_default_days,
|
||||||
|
};
|
||||||
|
if (orKey.trim()) body.openrouter_key = orKey.trim();
|
||||||
|
if (dtPw.trim()) body.delivery_password = dtPw.trim();
|
||||||
|
if (mirrorPw.trim()) body.mirror_password = mirrorPw.trim();
|
||||||
|
if (smtpPw.trim()) body.smtp_password = smtpPw.trim();
|
||||||
|
const r = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||||
|
if (r?.error) { notify(r.error); return; }
|
||||||
|
setOrKey(''); setDtPw(''); setMirrorPw(''); setSmtpPw(''); notify(t('Saved.')); load();
|
||||||
|
};
|
||||||
|
const testDeliveryTarget = async () => {
|
||||||
|
notify(t('Testing …'));
|
||||||
|
const r = await fetch('/api/admin/test-delivery', { method: 'POST' }).then((x) => x.json());
|
||||||
|
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||||
|
};
|
||||||
|
const [dtList, setDtList] = useState<any>(null);
|
||||||
|
const lsDeliveryTarget = async (path?: string) => {
|
||||||
|
notify(t('Reading folder on the delivery target …'));
|
||||||
|
const r = await fetch('/api/admin/delivery-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json());
|
||||||
|
setDtList(r); notify(r.error ? r.error : t('{n} entries in {path}', { n: (r.entries || []).length, path: r.path }));
|
||||||
|
};
|
||||||
|
const cleanupDeliveryTarget = async () => {
|
||||||
|
const gallery = s.delivery_default_folder || 'Gallery A';
|
||||||
|
notify(t('Cleaning up leftovers …'));
|
||||||
|
const r = await fetch('/api/admin/delivery-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cleanup_tmp', gallery }) }).then((x) => x.json());
|
||||||
|
notify(r.error ? r.error : t('{n} leftovers removed from “{folder}”.', { n: r.cleaned ?? 0, folder: gallery }));
|
||||||
|
};
|
||||||
|
const testMirror = async () => {
|
||||||
|
notify(t('Testing backup mirror …'));
|
||||||
|
const r = await fetch('/api/admin/test-mirror', { method: 'POST' }).then((x) => x.json());
|
||||||
|
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||||
|
};
|
||||||
|
const netcheck = async () => {
|
||||||
|
if (!s.mirror_host) { notify(t('Enter a host and save first.')); return; }
|
||||||
|
notify(t('Checking reachability …'));
|
||||||
|
const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host: s.mirror_host, port: s.mirror_port || 22 }) }).then((x) => x.json());
|
||||||
|
const dnsMsg = r.resolve ? (r.resolve.ok ? t('DNS ✓ ({address})', { address: r.resolve.address }) : t('DNS ✕ ({error})', { error: r.resolve.error })) : '';
|
||||||
|
const tcpMsg = r.tcp ? (r.tcp.ok ? t('Port reachable in {ms}ms ✓', { ms: r.tcp.ms }) : t('not reachable: {error}', { error: r.tcp.error })) : (r.error || t('Error'));
|
||||||
|
notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`);
|
||||||
|
};
|
||||||
|
const storageAction = async (action: string, confirmMsg?: string) => {
|
||||||
|
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||||
|
notify(t('Cleaning up …'));
|
||||||
|
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
|
||||||
|
notify(r.deleted != null ? t('{n} old items removed.', { n: r.deleted })
|
||||||
|
: r.purged != null ? t('{n} source files deleted.', { n: r.purged })
|
||||||
|
: r.made != null ? t('{n} preview images generated.', { n: r.made })
|
||||||
|
: (r.error || 'OK'));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const testMail = async (withSend: boolean) => {
|
||||||
|
if (withSend && !mailTo.trim()) { notify(t('Enter an address for the test message.')); return; }
|
||||||
|
notify(withSend ? t('Sending test message …') : t('Checking connection …'));
|
||||||
|
const r = await fetch('/api/admin/test-mail', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(withSend ? { to: mailTo.trim() } : {}) }).then((x) => x.json());
|
||||||
|
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unique file names — first show what would happen, then apply it.
|
||||||
|
const renamePreview = async () => {
|
||||||
|
notify(t('Checking file names …'));
|
||||||
|
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'rename_preview' }) }).then((x) => x.json());
|
||||||
|
if (r.error) { notify(r.error); return; }
|
||||||
|
setRename(r);
|
||||||
|
notify(r.renamed
|
||||||
|
? (r.wereDuplicates
|
||||||
|
? t('{n} of {checked} images would get a new name — {dupes} names are currently used twice.', { n: r.renamed, checked: r.checked, dupes: r.wereDuplicates })
|
||||||
|
: t('{n} of {checked} images would get a new name.', { n: r.renamed, checked: r.checked }))
|
||||||
|
: t('All {checked} names are already unique.', { checked: r.checked }));
|
||||||
|
};
|
||||||
|
const renameApply = async () => {
|
||||||
|
if (!rename) { notify(t('Run “Check library” first — then you can see what would change.')); return; }
|
||||||
|
if (!confirm(t('Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. '
|
||||||
|
+ 'Copies already placed on the delivery target keep their old name; that is what “Deliver again” is for afterwards.'))) return;
|
||||||
|
notify(t('Renaming …'));
|
||||||
|
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'rename_apply' }) }).then((x) => x.json());
|
||||||
|
setRename(r);
|
||||||
|
notify(r.error || t('{n} images renamed, {ok} were already fine.', { n: r.renamed, ok: r.unchanged }));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const redeliverAll = async () => {
|
||||||
|
if (!confirm(t('Upload all already delivered images again — under the new name?\n\n'
|
||||||
|
+ 'The old files stay on the target and have to be removed there by hand. This can take a while.'))) return;
|
||||||
|
notify(t('Delivering again … (may take a while)'));
|
||||||
|
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'redeliver_all' }) }).then((x) => x.json());
|
||||||
|
notify(r.attempted != null
|
||||||
|
? (r.failed
|
||||||
|
? t('{ok}/{tried} delivered again, {failed} failed.', { ok: r.ok, tried: r.attempted, failed: r.failed })
|
||||||
|
: t('{ok}/{tried} delivered again.', { ok: r.ok, tried: r.attempted }))
|
||||||
|
: (r.error || t('Error')));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const mirrorAll = async () => {
|
||||||
|
if (!s.mirror_enabled) { notify(t('Enable the backup mirror and save first.')); return; }
|
||||||
|
if (!confirm(t('Back up all finished images to the mirror?'))) return;
|
||||||
|
notify(t('Backing up to the mirror … (may take a while)'));
|
||||||
|
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
|
||||||
|
notify(r.total != null
|
||||||
|
? (r.failed
|
||||||
|
? t('{n}/{total} backed up to the mirror, {failed} failed.', { n: r.mirrored, total: r.total, failed: r.failed })
|
||||||
|
: t('{n}/{total} backed up to the mirror.', { n: r.mirrored, total: r.total }))
|
||||||
|
: (r.error || t('Error')));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const resetAll = async () => {
|
||||||
|
const answer = prompt(t('WARNING: Deletes ALL images, jobs and folders irreversibly (users, settings, models & preset recipes stay).\n\nType RESET to confirm:'));
|
||||||
|
if (answer !== 'RESET') { if (answer != null) notify(t('Cancelled — not confirmed.')); return; }
|
||||||
|
notify(t('Resetting …'));
|
||||||
|
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
|
||||||
|
notify(r.ok ? t('Reset done: {n} images removed.', { n: r.deleted_items }) : (r.error || t('Error')));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const addModel = async (m: any) => {
|
||||||
|
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const modelAction = async (id: string, action: string) => {
|
||||||
|
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, action }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const setPw = async (id: string) => {
|
||||||
|
const pw = prompt(t('New password?')); if (!pw) return;
|
||||||
|
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
|
||||||
|
notify(t('Password set.'));
|
||||||
|
};
|
||||||
|
const setUserPrivate = async (id: string, v: boolean) => {
|
||||||
|
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const addUser = async () => {
|
||||||
|
const username = prompt(t('Username?')); if (!username) return;
|
||||||
|
const password = prompt(t('Password?')); if (!password) return;
|
||||||
|
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!s) return <p className="hint">{t('Loading …')}</p>;
|
||||||
|
const stored = new Set(models.map((m) => m.model_id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin">
|
||||||
|
{stats && (
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Usage & cost')}</span></div>
|
||||||
|
<div className="numbers">
|
||||||
|
<div><b>{stats.total}</b><span>{t('Images total')}</span></div>
|
||||||
|
<div><b>{stats.error_rate}%</b><span>{t('Error rate')}</span></div>
|
||||||
|
<div><b>${stats.cost_30?.toFixed(2)}</b><span>{t('Cost 30 days')}</span></div>
|
||||||
|
<div><b>${stats.cost_all?.toFixed(2)}</b><span>{t('Cost total')}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t('Today ${today} · 7 days ${week}', { today: stats.cost_today?.toFixed(2), week: stats.cost_7?.toFixed(2) })}
|
||||||
|
{stats.last_activity ? ` · ${t('last {when}', { when: new Date(stats.last_activity).toLocaleString() })}` : ''}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Access & limits')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="field"><label>{t('OpenRouter key')} {s.openrouter_key_set && <em>({s.openrouter_key_masked})</em>}</label>
|
||||||
|
<input className="input" placeholder={s.openrouter_key_set ? t('Enter a new key to replace it') : 'sk-or-…'} value={orKey} onChange={(e) => setOrKey(e.target.value)} />
|
||||||
|
<div className="hint">{t('Stored encrypted, takes precedence over the environment variable.')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Monthly budget ($)')}</label>
|
||||||
|
<input className="input" type="number" step="0.5" value={s.monthly_budget ?? ''} onChange={(e) => field('monthly_budget', e.target.value)} />
|
||||||
|
<div className="hint">{t('When it is reached the queue stops.')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Cricut sheet size (cm)')}</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Default file format')}</label>
|
||||||
|
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
|
||||||
|
<option value="png">{t('PNG (lossless, large)')}</option>
|
||||||
|
<option value="jpg">{t('JPG (small, easy on the delivery target)')}</option>
|
||||||
|
</select>
|
||||||
|
<div className="hint">{t('Applies to new conversions without their own format choice. Cut-out subjects always stay PNG.')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Delivery target')}</span>
|
||||||
|
<div className="row"><button className="mini" onClick={() => lsDeliveryTarget()}>{t('Show folder')}</button>
|
||||||
|
<button className="mini" onClick={cleanupDeliveryTarget}>{t('Clean up leftovers')}</button>
|
||||||
|
<button className="mini" onClick={testDeliveryTarget}>{t('Test connection')}</button></div></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Host')}</label><input className="input" value={s.delivery_host ?? ''} onChange={(e) => field('delivery_host', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={s.delivery_port ?? 22} onChange={(e) => field('delivery_port', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Protocol')}</label>
|
||||||
|
<select className="input" value={s.delivery_protocol ?? 'sftp'} onChange={(e) => field('delivery_protocol', e.target.value)}>
|
||||||
|
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
|
||||||
|
<div className="field"><label>{t('User')}</label><input className="input" value={s.delivery_user ?? ''} onChange={(e) => field('delivery_user', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Password')} {s.delivery_password_set && <em>({t('set')})</em>}</label>
|
||||||
|
<input className="input" type="password" placeholder={s.delivery_password_set ? '••••••' : ''} value={dtPw} onChange={(e) => setDtPw(e.target.value)} /></div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Base folder (SFTP root)')}</label><input className="input" value={s.delivery_base_path ?? '/'} onChange={(e) => field('delivery_base_path', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Default folder on the target')}</label><input className="input" placeholder="Gallery A" value={s.delivery_default_folder ?? ''} onChange={(e) => field('delivery_default_folder', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.delivery_metadata_sidecar} onChange={(e) => field('delivery_metadata_sidecar', e.target.checked)} />
|
||||||
|
<span><b>{t('Send the sidecar file (.md) to the delivery target')}</b><em>{t('Only switch this on if the target is supposed to process the text files — otherwise leave it off.')}</em></span></label>
|
||||||
|
<div className="hint">{t('Images land in base folder / folder on the target — for example:')} <code>/ + Gallery A</code>{' '}
|
||||||
|
{t('“Show folder” lists what actually sits where over SFTP.')}</div>
|
||||||
|
{dtList && (
|
||||||
|
<div className="dtlist">
|
||||||
|
<div className="hint"><b>{dtList.path}</b>{dtList.error ? ` — ${dtList.error}` : ''}</div>
|
||||||
|
{(dtList.entries || []).map((e: any, i: number) => (
|
||||||
|
<div key={i} className="dtrow">
|
||||||
|
<button className="link2" onClick={() => lsDeliveryTarget((dtList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
|
||||||
|
<span className="hint">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Storage & cleanup')}</span>
|
||||||
|
{storage && <span className="hint">{t('{bytes} · {results} results · {sources} sources · {thumbs} previews',
|
||||||
|
{ bytes: fmtBytes(storage.bytes), results: storage.results, sources: storage.sources, thumbs: storage.thumbs })}</span>}</div>
|
||||||
|
<div className="controls">
|
||||||
|
<label className="toggle"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
|
||||||
|
<span><b>{t('Generate preview images')}</b><em>{t('Small, economical images for the library preview.')}</em></span></label>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
||||||
|
<span><b>{t('Delete source images after processing')}</b><em>{t('Saves space — originals are removed once the result exists.')}</em></span></label>
|
||||||
|
<div className="hint">{t('The metadata sidecar file (.md with prompt/model/format) can now be switched on and off per target — on the delivery target, on the backup mirror and on every FTP target separately (see the card for each).')}</div>
|
||||||
|
<div className="field"><label>{t('Retention (days)')}</label>
|
||||||
|
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||||
|
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
||||||
|
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
||||||
|
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', t('Delete items older than {days} days now?', { days: s.retention_days }))}>{t('Apply retention now')}</button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Email')}</span>
|
||||||
|
<div className="row">
|
||||||
|
<button className="mini" onClick={() => testMail(false)}>{t('Test connection')}</button>
|
||||||
|
<button className="mini" onClick={() => testMail(true)}>{t('Test message')}</button>
|
||||||
|
</div></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="hint">{t('Klarbild sends through your own mailbox — that way SPF and DKIM are right by themselves and no further provider is involved.')}{' '}
|
||||||
|
{t('It is needed for password reset, notices about finished jobs and share links by mail.')}{' '}
|
||||||
|
{t('The password is stored encrypted and never shown again.')}</div>
|
||||||
|
{s.smtp_source === 'environment' && s.smtp_effective && (
|
||||||
|
<div className="notice">
|
||||||
|
{t('Sending already works — through the server setting ({user} on {host}:{port}).',
|
||||||
|
{ user: s.smtp_effective.user, host: s.smtp_effective.host, port: s.smtp_effective.port })}{' '}
|
||||||
|
{t('The fields below are empty because nothing is entered here; that is fine.')}{' '}
|
||||||
|
{t('Anyone who enters something here and saves it overwrites the server setting.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.smtp_source === 'database' && (
|
||||||
|
<div className="notice">{t('Sending runs on what is entered here.')}</div>
|
||||||
|
)}
|
||||||
|
{!s.smtp_source && (
|
||||||
|
<div className="notice warn">{t('No sending set up yet — forgotten password, job notices and mailing share links do not work until then.')}</div>
|
||||||
|
)}
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Outgoing mail server')}</label>
|
||||||
|
<input className="input" placeholder={t('e.g. smtp.example.com')} value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Port')}</label>
|
||||||
|
<input className="input" type="number" placeholder="465" value={s.smtp_port ?? 465} onChange={(e) => field('smtp_port', e.target.value)} />
|
||||||
|
<div className="hint">{t('465 = SSL · 587 = STARTTLS')}</div></div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={s.smtp_secure !== false} onChange={(e) => field('smtp_secure', e.target.checked)} />
|
||||||
|
<span><b>{t('Connect encrypted (SSL)')}</b><em>{t('On for port 465, off for 587.')}</em></span></label>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Username')}</label>
|
||||||
|
<input className="input" placeholder="mail@your-domain.com" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Password')} {s.smtp_password_set ? <span className="hint">· {t('set')}</span> : null}</label>
|
||||||
|
<input className="input" type="password" value={smtpPw} autoComplete="new-password"
|
||||||
|
placeholder={s.smtp_password_set ? t('leave unchanged') : t('Mailbox password')}
|
||||||
|
onChange={(e) => setSmtpPw(e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Sender')}</label>
|
||||||
|
<input className="input" placeholder="Klarbild <mail@your-domain.com>" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
|
||||||
|
<div className="hint">{t('Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.')}</div></div>
|
||||||
|
<div className="field"><label>{t('Test message to')}</label>
|
||||||
|
<input className="input" type="email" placeholder="you@example.com" value={mailTo} onChange={(e) => setMailTo(e.target.value)} />
|
||||||
|
<div className="hint">{t('“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.')}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Share links')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="field"><label>{t('Default expiry (days)')}</label>
|
||||||
|
<input className="input" type="number" min={0} max={3650} value={s.share_default_days ?? 30}
|
||||||
|
onChange={(e) => field('share_default_days', e.target.value)} />
|
||||||
|
<div className="hint">{t('Default for new links; changeable at any time when one is created.')} <b>{t('0 = unlimited.')}</b>{' '}
|
||||||
|
{t('There are children in the images — a link that expires by itself is the safe default.')}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('File names')}</span>
|
||||||
|
<span className="hint">{t('YYYY-MM-DD_HHMMSS_subject_format_shortid')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="hint">{t('Every result gets a name that starts with date and time — that way every file list sorts chronologically by itself — and ends with a short id taken from the image ID.')}{' '}
|
||||||
|
{t('That way two similar images from the same day can no longer overwrite each other.')}{' '}
|
||||||
|
{t('Example:')} <code>2026-08-20_143207_portrait_the-frame_a3f9.jpg</code></div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="mini" onClick={renamePreview}>{t('Check library')}</button>
|
||||||
|
<button className="mini" onClick={renameApply}>{t('Rename now')}</button>
|
||||||
|
<button className="mini" onClick={redeliverAll}>{t('Deliver again')}</button>
|
||||||
|
</div>
|
||||||
|
{rename && (
|
||||||
|
<div className="hint">
|
||||||
|
<b>{t('{n} images checked', { n: rename.checked })}</b> ·{' '}
|
||||||
|
{rename.dryRun
|
||||||
|
? t('{n} would get a new name', { n: rename.renamed })
|
||||||
|
: t('{n} renamed', { n: rename.renamed })} ·
|
||||||
|
{' '}{t('{n} unchanged', { n: rename.unchanged })}
|
||||||
|
{rename.wereDuplicates ? ` · ${t('{n} names were used twice', { n: rename.wereDuplicates })}` : ''}
|
||||||
|
{rename.examples?.length ? (
|
||||||
|
<ul className="namelist">
|
||||||
|
{rename.examples.map((b: any) => (
|
||||||
|
<li key={b.id}><s>{b.before}</s> → <code>{b.after}</code></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hint">{t('“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.')}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Backup mirror (second copy)')}</span>
|
||||||
|
<div className="row"><button className="mini" onClick={netcheck}>{t('Reachability')}</button>
|
||||||
|
<button className="mini" onClick={testMirror}>{t('Test connection')}</button></div></div>
|
||||||
|
<div className="controls">
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.mirror_enabled} onChange={(e) => field('mirror_enabled', e.target.checked)} />
|
||||||
|
<span><b>{t('Mirror results to the backup mirror as well')}</b><em>{t('Every finished image is backed up to {path}.', { path: 'klarbild/YYYY-MM/' })}</em></span></label>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Host')}</label><input className="input" placeholder={t('e.g. mirror.local or DDNS')} value={s.mirror_host ?? ''} onChange={(e) => field('mirror_host', e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={s.mirror_port ?? 22} onChange={(e) => field('mirror_port', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Protocol')}</label>
|
||||||
|
<select className="input" value={s.mirror_protocol ?? 'sftp'} onChange={(e) => field('mirror_protocol', e.target.value)}>
|
||||||
|
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
|
||||||
|
<div className="field"><label>{t('User')}</label><input className="input" value={s.mirror_user ?? ''} onChange={(e) => field('mirror_user', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Password')} {s.mirror_password_set && <em>({t('set')})</em>}</label>
|
||||||
|
<input className="input" type="password" placeholder={s.mirror_password_set ? '••••••' : ''} value={mirrorPw} onChange={(e) => setMirrorPw(e.target.value)} /></div>
|
||||||
|
<div className="field"><label>{t('Base folder on the mirror')}</label><input className="input" placeholder="/Klarbild" value={s.mirror_base_path ?? ''} onChange={(e) => field('mirror_base_path', e.target.value)} /></div>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.mirror_metadata_sidecar} onChange={(e) => field('mirror_metadata_sidecar', e.target.checked)} />
|
||||||
|
<span><b>{t('Send the sidecar file (.md) to the mirror')}</b><em>{t('Companion text file (prompt, model, format …) next to every backed-up image.')}</em></span></label>
|
||||||
|
<button className="mini" onClick={mirrorAll}>{t('Back up all existing images to the mirror')}</button>
|
||||||
|
<div className="hint">{t('Results land under base folder / YYYY-MM /. Credentials are stored encrypted.')}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('More output targets (FTP/SFTP)')}</span>
|
||||||
|
<button className="mini" onClick={backupAllTargets}>{t('Back up all images')}</button></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="hint">{t('Additional FTP/SFTP targets — for example a second folder on the delivery target or another server.')}{' '}
|
||||||
|
{t('A preset/folder can point at one target permanently; selectable in the studio under “Where to?” (the backup mirror too).')}{' '}
|
||||||
|
{t('“Backup” targets automatically receive all results (like the backup mirror).')}</div>
|
||||||
|
<div className="list">
|
||||||
|
{targets.map((tg) => (
|
||||||
|
<div key={tg.id} className="item">
|
||||||
|
<div><b>{tg.name}</b>{tg.is_backup && <span className="pill">{t('Backup')}</span>}{tg.metadata_sidecar && <span className="pill alpha">.md</span>} <span className="hint">{tg.protocol}://{tg.username}@{tg.host}:{tg.port} · {tg.base_path || '/'}{tg.password_set ? '' : ` · ⚠︎ ${t('no password')}`}</span></div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="mini" onClick={() => toggleBackup(tg)}>{tg.is_backup ? t('Backup off') : t('As backup')}</button>
|
||||||
|
<button className="mini" onClick={() => toggleTargetSidecar(tg)}>{tg.metadata_sidecar ? t('.md off') : t('.md on')}</button>
|
||||||
|
<button className="mini" onClick={() => testTarget(tg.id)}>{t('Test')}</button>
|
||||||
|
<button className="mini" onClick={() => delTarget(tg.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. The Frame FTP')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
||||||
|
<div className="field"><label>{t('Protocol')}</label>
|
||||||
|
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
||||||
|
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('Host')}</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
|
||||||
|
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="two">
|
||||||
|
<div className="field"><label>{t('User')}</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
|
||||||
|
<div className="field"><label>{t('Password')}</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>{t('Base folder')}</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||||
|
<span><b>{t('As a backup target')}</b><em>{t('Receives all results automatically.')}</em></span></label>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
|
||||||
|
<span><b>{t('Send the sidecar file (.md) along')}</b><em>{t('Companion text file per image.')}</em></span></label>
|
||||||
|
<button className="mini" onClick={addTarget}>{t('Add target')}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Automation / MCP access')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="hint">{t('API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).')}{' '}
|
||||||
|
{t('As {header} to {uploads} and {jobs}.', { header: 'Authorization: Bearer <Token>', uploads: '/api/uploads', jobs: '/api/jobs' })}</div>
|
||||||
|
{s.api_token
|
||||||
|
? <div className="field"><label>{t('API token')}</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
|
||||||
|
: <div className="hint">{t('No token generated yet.')}</div>}
|
||||||
|
<button className="mini" onClick={genToken}>{s.api_token ? t('Generate a new token') : t('Generate token')}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Permissions & privacy')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="field"><label>{t('Who sees which generations?')}</label>
|
||||||
|
<div className="choice">
|
||||||
|
<button className={s.library_visibility !== 'shared' ? 'on' : ''} onClick={() => field('library_visibility', 'own')}>{t('Own only')}</button>
|
||||||
|
<button className={s.library_visibility === 'shared' ? 'on' : ''} onClick={() => field('library_visibility', 'shared')}>{t('Everyone sees everything')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t('Admins always see everything. “Own only”: everyone sees only the images they generated themselves.')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
|
||||||
|
<span><b>{t('Anonymous generations')}</b><em>{t('Who generated an image is hidden from non-admins.')}</em></span></label>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
|
||||||
|
<span><b>{t('Allow private sessions')}</b><em>{t('Users get the “Private session” switch in the studio (generate only, forget afterwards).')}</em></span></label>
|
||||||
|
<label className="toggle"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
|
||||||
|
<span><b>{t('Allow NSFW models')}</b><em>{t('Models marked as NSFW become selectable and visible. The responsibility lies with the operator; content involving minors is prohibited without exception.')}</em></span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button className="btn" onClick={saveSettings}>{t('Save settings')}</button>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Models')}</span></div>
|
||||||
|
<div className="list">
|
||||||
|
{models.map((m) => (
|
||||||
|
<div key={m.id} className="item">
|
||||||
|
<div><b>{m.label}</b>{m.is_default && <span className="pill">{t('Default')}</span>}{m.supports_alpha && <span className="pill alpha">{t('transparent')}</span>}{m.nsfw && <span className="pill nsfw">NSFW</span>}<div className="hint">{m.model_id}</div></div>
|
||||||
|
<div className="row">
|
||||||
|
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>{t('Default')}</button>}
|
||||||
|
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? t('NSFW off') : 'NSFW'}</button>
|
||||||
|
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{models.length === 0 && <div className="hint">{t('No models yet. Add one from the available ones:')}</div>}
|
||||||
|
<div className="avail">
|
||||||
|
{avail.filter((m) => !stored.has(m.model_id)).slice(0, 12).map((m) => (
|
||||||
|
<button key={m.model_id} className="mini" onClick={() => addModel(m)}>+ {m.name}{m.supports_alpha ? ' ◇' : ''}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Users')}</span><button className="mini" onClick={addUser}>{t('New user')}</button></div>
|
||||||
|
<div className="list">
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="item">
|
||||||
|
<div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pill">{t('private')}</span>} <span className="hint">{u.username} · {u.role}</span></div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? t('Private off') : t('Force private')}</button>
|
||||||
|
<button className="mini" onClick={() => pairCode(u.id)}>{t('Pair Telegram')}</button>
|
||||||
|
<button className="mini" onClick={() => setPw(u.id)}>{t('Set password')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Telegram bot')}</span></div>
|
||||||
|
<div className="list">
|
||||||
|
<div className="hint">{t('Generate a code with “Pair Telegram” on a user and enter it in the chat with the bot. After that, forward images there → pick a preset recipe → results come back.')}</div>
|
||||||
|
{tgLinks.map((l) => (
|
||||||
|
<div key={l.chat_id} className="item">
|
||||||
|
<div><b>{t('Chat {id}', { id: l.chat_id })}</b> <span className="hint">{l.user_name || ''} · {t('since {date}', { date: new Date(l.linked_at).toLocaleDateString() })}</span></div>
|
||||||
|
<button className="mini" onClick={() => unlink(l.chat_id)}>{t('Unlink')}</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tgLinks.length === 0 && <div className="hint">{t('No paired chats yet.')}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card danger">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Reset')}</span></div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="hint">{t('Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.')}</div>
|
||||||
|
<button className="btn warn" onClick={resetAll}>{t('Reset library & jobs')}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
|
<AdminStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminStyles() {
|
||||||
|
return <style>{`
|
||||||
|
.admin{display:flex;flex-direction:column;gap:16px;max-width:640px;}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||||
|
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||||
|
.controls{padding:16px;display:flex;flex-direction:column;gap:14px;}
|
||||||
|
.two{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
|
||||||
|
.field label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;}
|
||||||
|
.field em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;}
|
||||||
|
/* 16 px, not 14: below that iOS zooms into the field on focus — the
|
||||||
|
emergency brake in tokens.css only reaches 767 px, but the admin is also
|
||||||
|
used on the iPad. */
|
||||||
|
.input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:3px;padding:10px 11px;font-family:inherit;font-size:16px;color:var(--ink);}
|
||||||
|
.hint{font-size:11.5px;color:var(--soft);line-height:1.5;}
|
||||||
|
.btn{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||||
|
.btn.warn{background:var(--err);}
|
||||||
|
.card.danger{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
|
||||||
|
.card.danger .mono-label{color:var(--err);}
|
||||||
|
.controls code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||||
|
.numbers{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
|
||||||
|
.numbers div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
|
||||||
|
.numbers b{display:block;font-size:22px;line-height:1;}.numbers span{font-size:11.5px;color:var(--soft);}
|
||||||
|
.card>.hint{padding:0 16px 14px;}
|
||||||
|
.notice{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||||
|
background:var(--paper);font-size:12.5px;line-height:1.55;}
|
||||||
|
.notice.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);}
|
||||||
|
.notice code{font-family:var(--font-mono);font-size:11.5px;}
|
||||||
|
/* On a phone everything was two columns — a field like "Outgoing mail
|
||||||
|
server" ended up around 156 px wide. And tap targets under 44 px are
|
||||||
|
not hit reliably. */
|
||||||
|
@media(max-width:560px){
|
||||||
|
.two{grid-template-columns:1fr;}
|
||||||
|
.mini{padding:11px 13px;font-size:13px;}
|
||||||
|
.row{flex-wrap:wrap;}
|
||||||
|
}
|
||||||
|
/* Before/after of the rename — long names must not blow the card apart. */
|
||||||
|
.namelist{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;}
|
||||||
|
.namelist li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;}
|
||||||
|
.namelist s{color:var(--soft);}
|
||||||
|
.namelist code{color:var(--ink);}
|
||||||
|
.list{padding:8px 16px 16px;display:flex;flex-direction:column;}
|
||||||
|
.item{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);}
|
||||||
|
.item b{font-size:14px;}
|
||||||
|
.pill{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
|
||||||
|
.pill.alpha{background:var(--paper);color:var(--soft);}
|
||||||
|
.pill.nsfw{background:var(--err);color:#fff;}
|
||||||
|
.toggle{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
||||||
|
.toggle input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||||
|
.toggle b{display:block;font-size:14px;}
|
||||||
|
.toggle em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
|
||||||
|
.toggle code{font-family:var(--font-mono);font-size:11px;}
|
||||||
|
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
|
||||||
|
.row{display:flex;gap:6px;}
|
||||||
|
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
|
||||||
|
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||||
|
.dtlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;}
|
||||||
|
.dtrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;}
|
||||||
|
.link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;}
|
||||||
|
`}</style>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { filenameFromHeader } from '../lib/naming';
|
||||||
|
import ShareDialog, { type ShareTarget } from './ShareDialog';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
||||||
|
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; mirror_status: string;
|
||||||
|
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
|
||||||
|
color_tag: string | null; created_at: string; }
|
||||||
|
|
||||||
|
const TAGS = [
|
||||||
|
{ id: 'red', label: 'Red', color: '#E5484D' },
|
||||||
|
{ id: 'orange', label: 'Orange', color: '#E8830C' },
|
||||||
|
{ id: 'green', label: 'Green', color: '#46A758' },
|
||||||
|
{ id: 'final', label: 'Final', color: '#3A3733' },
|
||||||
|
];
|
||||||
|
const tagColor = (id: string | null) => TAGS.find((x) => x.id === id)?.color || null;
|
||||||
|
|
||||||
|
export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [folders, setFolders] = useState<any[]>([]);
|
||||||
|
const [models, setModels] = useState<any[]>([]);
|
||||||
|
const [folderFilter, setFolderFilter] = useState<string | null>(null);
|
||||||
|
const [pick, setPick] = useState<Set<string>>(new Set());
|
||||||
|
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
||||||
|
const [compare, setCompare] = useState<Item | null>(null);
|
||||||
|
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
||||||
|
const [saveView, setSaveView] = useState<Item | null>(null);
|
||||||
|
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||||
|
const [share, setShare] = useState<ShareTarget | null>(null);
|
||||||
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
|
||||||
|
|
||||||
|
// Model name: the admin label, otherwise the readable tail of the model ID.
|
||||||
|
const modelName = (id: string | null) => {
|
||||||
|
if (!id) return null;
|
||||||
|
const m = models.find((x) => x.model_id === id);
|
||||||
|
if (m) return m.label;
|
||||||
|
return id.split('/').pop()!.replace(/-/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [i, f, m] = await Promise.all([
|
||||||
|
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||||
|
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
||||||
|
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
||||||
|
]);
|
||||||
|
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const rename = async (id: string, filename: string) =>
|
||||||
|
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
||||||
|
const setTag = async (id: string, color_tag: string | null) => {
|
||||||
|
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
|
||||||
|
setTagMenu(null);
|
||||||
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
|
||||||
|
};
|
||||||
|
const setFolder = async (id: string, folder_id: string | null) => {
|
||||||
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
||||||
|
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify(t('Deleted.')); };
|
||||||
|
const deliverSelected = async () => {
|
||||||
|
notify(t('Sending to the delivery target …'));
|
||||||
|
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
||||||
|
setPick(new Set()); load(); notify(t('{done}/{total} delivered to the target.', { done: r.delivered, total: r.total }));
|
||||||
|
};
|
||||||
|
const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
const moveSelected = async (folder_id: string | null) => {
|
||||||
|
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||||
|
setPick(new Set()); load(); notify(folder_id ? t('Moved into the folder.') : t('Removed from the folder.'));
|
||||||
|
};
|
||||||
|
const zipSelected = async () => {
|
||||||
|
notify(t('Packing ZIP …'));
|
||||||
|
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
|
||||||
|
if (!res.ok) { notify(t('ZIP failed.')); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||||
|
// The name comes from the server — building our own here would mean two
|
||||||
|
// selections made on the same day end up with the same name again.
|
||||||
|
a.download = filenameFromHeader(res.headers.get('Content-Disposition'), 'klarbild-selection.zip'); a.click();
|
||||||
|
URL.revokeObjectURL(a.href); notify(t('ZIP downloaded.'));
|
||||||
|
};
|
||||||
|
const alternative = async (id: string) => {
|
||||||
|
notify(t('Generating an alternative …'));
|
||||||
|
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
|
||||||
|
if (r.ok) notify(t('The alternative is being generated — it will show up here shortly.')); else notify(r.error || t('Error.'));
|
||||||
|
setTimeout(load, 2500);
|
||||||
|
};
|
||||||
|
// Carry the result into the studio as a new source and keep working on it.
|
||||||
|
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
|
||||||
|
|
||||||
|
const newFolder = async () => {
|
||||||
|
const name = prompt(t('Folder name?')); if (!name) return;
|
||||||
|
const targetFolder = prompt(t('Folder on the delivery target for this folder? (empty = default)')) || '';
|
||||||
|
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, delivery_folder: targetFolder.trim() || null }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const delFolder = async (id: string) => {
|
||||||
|
if (!confirm(t('Delete folder? The images are kept.'))) return;
|
||||||
|
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
|
||||||
|
if (folderFilter === id) setFolderFilter(null);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const renameFolder = async (o: any) => {
|
||||||
|
const name = prompt(t('Rename folder:'), o.name); if (name == null) return;
|
||||||
|
const targetFolder = prompt(t('Folder on the delivery target (empty = default):'), o.delivery_folder || '');
|
||||||
|
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, delivery_folder: (targetFolder || '').trim() || null }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by folder and flag, then group alternatives under their origin.
|
||||||
|
const filtered = items
|
||||||
|
.filter((v) => !folderFilter || v.folder_id === folderFilter)
|
||||||
|
.filter((v) => !tagFilter || v.color_tag === tagFilter);
|
||||||
|
const groupMap = new Map<string, Item[]>();
|
||||||
|
for (const it of filtered) {
|
||||||
|
const root = it.variant_of || it.id;
|
||||||
|
if (!groupMap.has(root)) groupMap.set(root, []);
|
||||||
|
groupMap.get(root)!.push(it);
|
||||||
|
}
|
||||||
|
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
|
||||||
|
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
|
||||||
|
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="head-row">
|
||||||
|
<span className="mono-label">{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
||||||
|
<div className="row">
|
||||||
|
{pick.size > 0 && <>
|
||||||
|
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
||||||
|
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
||||||
|
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
||||||
|
<button className="mini" onClick={zipSelected}>{t('Download ZIP')}</button>
|
||||||
|
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
|
||||||
|
<option value="" disabled>{t('Into folder …')}</option>
|
||||||
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||||
|
<option value="__none">{t('No folder')}</option>
|
||||||
|
</select>
|
||||||
|
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
||||||
|
</>}
|
||||||
|
<button className="mini" onClick={newFolder}>{t('New folder')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder filter */}
|
||||||
|
<div className="folderbar">
|
||||||
|
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
||||||
|
{folders.map((o) => (
|
||||||
|
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'active' : ''}`}>
|
||||||
|
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
|
||||||
|
{o.name} · {o.count}{o.delivery_folder ? ' →' : ''}
|
||||||
|
</button>
|
||||||
|
{folderFilter === o.id && <>
|
||||||
|
<button className="chip-x" title={t('Rename folder')} onClick={() => renameFolder(o)}>✎</button>
|
||||||
|
<button className="chip-x del" title={t('Delete folder')} onClick={() => delFolder(o.id)}>✕</button>
|
||||||
|
</>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hint notice">{t('Folders group the library, and each folder can get its own folder on the delivery target (arrow = its own target folder is set).')}</div>
|
||||||
|
|
||||||
|
{/* Filter by color flag */}
|
||||||
|
<div className="folderbar tagbar">
|
||||||
|
<button className={`chip ${!tagFilter ? 'active' : ''}`} onClick={() => setTagFilter(null)}>{t('All flags')}</button>
|
||||||
|
{TAGS.map((tag) => (
|
||||||
|
<button key={tag.id} className={`chip flagchip ${tagFilter === tag.id ? 'active' : ''}`} onClick={() => setTagFilter(tagFilter === tag.id ? null : tag.id)}>
|
||||||
|
<span className="flag" style={{ color: tag.color }}>⚑</span> {t(tag.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="empty"><span className="regmark" /><h3>{folderFilter ? t('Folder is empty') : t('Nothing created yet')}</h3>
|
||||||
|
<p>{folderFilter ? t('Assign images to this folder from the folder menu.') : t('Start in the studio — the results collect here.')}</p></div>
|
||||||
|
) : (
|
||||||
|
<div className="gallery">
|
||||||
|
{groups.map((g) => {
|
||||||
|
const v = shown(g);
|
||||||
|
const multi = g.length > 1;
|
||||||
|
return (
|
||||||
|
<article key={g[0].id} className={`tile ${pick.has(v.id) ? 'selected' : ''}`}>
|
||||||
|
<div className="tile-image" onClick={() => toggle(v.id)}>
|
||||||
|
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||||
|
<span className="check">{pick.has(v.id) ? '✓' : ''}</span>
|
||||||
|
{(v.mode === 'compose' || v.mode === 'generate') &&
|
||||||
|
<span className="modetag">{v.mode === 'compose' ? t('combined') : t('generated')}</span>}
|
||||||
|
{v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}>⚑</span>}
|
||||||
|
{multi && <span className="version">{t('Version {n}/{total}', { n: g.indexOf(v) + 1, total: g.length })}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{multi && (
|
||||||
|
<div className="variants">
|
||||||
|
{g.map((x, idx) => (
|
||||||
|
<button key={x.id} className={`vthumb ${x.id === v.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={t('Version {n}', { n: idx + 1 })}>
|
||||||
|
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||||
|
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
|
||||||
|
{v.delivery_status === 'delivered' && <span className="dbadge ok">{t('Delivered')} ✓</span>}
|
||||||
|
{v.delivery_status === 'pending' && <span className="dbadge">{t('Delivering')} …</span>}
|
||||||
|
{v.delivery_status === 'failed' && <span className="dbadge err">{t('Delivery')} ✕</span>}
|
||||||
|
{v.mirror_status === 'mirrored' && <span className="dbadge ok">{t('Mirror')} ✓</span>}
|
||||||
|
{v.mirror_status === 'failed' && <span className="dbadge err">{t('Mirror')} ✕</span>}
|
||||||
|
</div>
|
||||||
|
<div className="row tight">
|
||||||
|
<div className="tagwrap">
|
||||||
|
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
|
||||||
|
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title={t('Flag')}>⚑</button>
|
||||||
|
{tagMenu === v.id && (
|
||||||
|
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
|
||||||
|
<button onClick={() => setTag(v.id, null)}><span className="flag none">⚑</span> {t('None')}</button>
|
||||||
|
{TAGS.map((tag) => (
|
||||||
|
<button key={tag.id} onClick={() => setTag(v.id, tag.id)}><span className="flag" style={{ color: tag.color }}>⚑</span> {t(tag.label)}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>{t('Prompt')}</button>}
|
||||||
|
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>{t('Before/After')}</button>}
|
||||||
|
<button className="mini strong2" onClick={() => setSaveView(v)} title={t('Open large, then save (iPhone: Save to Photos)')}>{t('Download')}</button>
|
||||||
|
<button className="mini" onClick={() => reuse(v.id)} title={t('Keep working on the result in the studio')}>{t('Continue')}</button>
|
||||||
|
<button className="mini" onClick={() => alternative(v.id)} title={t('Generate another version')}>{t('+ Alt.')}</button>
|
||||||
|
<button className="mini" onClick={() => setShare({ itemIds: [v.id] })}
|
||||||
|
title={t('Create a link — opens without a Klarbild account')}>{t('Share')}</button>
|
||||||
|
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||||
|
<option value="">{t('No folder')}</option>
|
||||||
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{share && (
|
||||||
|
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{compare && (
|
||||||
|
<div className="lightbox" onClick={() => setCompare(null)}>
|
||||||
|
<div className="compare" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img className="under" src={`/api/items/${compare.id}/file?src=1`} alt={t('before')} />
|
||||||
|
<img className="over" src={`/api/items/${compare.id}/file`} alt={t('after')} id="ov" />
|
||||||
|
<input type="range" min={0} max={100} defaultValue={50}
|
||||||
|
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
|
||||||
|
</div>
|
||||||
|
<div className="lightbox-foot">{compare.filename} · {compare.output_px}px — {compare.mode === 'compose'
|
||||||
|
? t('Slider: source on the left, result on the right')
|
||||||
|
: t('Slider: original on the left, result on the right')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveView && (
|
||||||
|
<div className="lightbox" onClick={() => setSaveView(null)}>
|
||||||
|
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
||||||
|
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
||||||
|
</div>
|
||||||
|
<div className="save-foot" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span>{t('📱 iPhone/iPad: press and hold the image → “Save to Photos”.')}</span>
|
||||||
|
<a className="mini strong" href={`/api/items/${saveView.id}/file?download=1`}>{t('Original as a file (.png)')}</a>
|
||||||
|
<button className="mini" onClick={() => setSaveView(null)}>{t('Close')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{promptOf && (
|
||||||
|
<div className="lightbox" onClick={() => setPromptOf(null)}>
|
||||||
|
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="pb-head"><b>{promptOf.filename}</b>
|
||||||
|
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify(t('Prompt copied.')); }}>{t('Copy')}</button></div>
|
||||||
|
<pre className="pb-text">{promptOf.prompt_used}</pre>
|
||||||
|
<div className="hint">{promptOf.model_used ? `${t('Model: {name}', { name: modelName(promptOf.model_used) || '' })} · ` : ''}{promptOf.output_px}px</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
|
<LibStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibStyles() {
|
||||||
|
return <style>{`
|
||||||
|
.head-row{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.row.tight{margin-top:8px;}
|
||||||
|
.hint{font-size:11.5px;color:var(--soft);}
|
||||||
|
.notice{margin:0 0 14px;line-height:1.5;}
|
||||||
|
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||||
|
.folderbar{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
|
||||||
|
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
|
||||||
|
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
|
||||||
|
.chip.active,.chip-wrap.active .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
|
||||||
|
.chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
|
||||||
|
.chip-x.del{color:var(--err);padding:0 8px 0 2px;}
|
||||||
|
.empty{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.empty h3{margin:0;font-size:17px;}.empty p{margin:0;color:var(--soft);max-width:38ch;}
|
||||||
|
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
|
||||||
|
@media(min-width:768px){.gallery{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;}}
|
||||||
|
.tile{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:8px;}
|
||||||
|
.tile.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||||
|
.tile-image{position:relative;border-radius:3px;overflow:hidden;cursor:pointer;
|
||||||
|
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/16px 16px;}
|
||||||
|
.tile-image img{width:100%;height:auto;display:block;transition:transform .12s;}
|
||||||
|
.tile-image:active img{transform:scale(.98);}
|
||||||
|
.check{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||||
|
.tile.selected .check{background:var(--accent);}
|
||||||
|
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
|
||||||
|
/* Bottom left — the selection circle (.check) sits top left, the mode tag top
|
||||||
|
right, the version number bottom right. The flag used to sit exactly on the
|
||||||
|
selection circle and covered it. */
|
||||||
|
.flagbadge{position:absolute;bottom:5px;left:6px;font-size:19px;line-height:1;
|
||||||
|
text-shadow:0 1px 3px rgba(0,0,0,.55);pointer-events:none;}
|
||||||
|
.tagbar{margin:0 0 12px;}
|
||||||
|
.flagchip .flag{font-size:13px;vertical-align:-1px;}
|
||||||
|
.flag.none{color:var(--soft);}
|
||||||
|
.tagwrap{position:relative;display:inline-block;}
|
||||||
|
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
|
||||||
|
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
|
||||||
|
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
|
||||||
|
.tagmenu button:hover{background:var(--paper);}
|
||||||
|
.tagmenu .flag{font-size:15px;}
|
||||||
|
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
|
||||||
|
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||||
|
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
|
||||||
|
.saveimg.full{position:relative;z-index:1;}
|
||||||
|
.save-foot{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
|
||||||
|
.version{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
||||||
|
.variants{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
||||||
|
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
|
||||||
|
.vthumb.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||||
|
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||||
|
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
|
||||||
|
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
|
||||||
|
.mini.strong{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
||||||
|
.mini.strong2{border-color:var(--accent);color:var(--accent);font-weight:600;}
|
||||||
|
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
|
||||||
|
.dbadge.ok{background:var(--accent-bg);color:var(--accent);}
|
||||||
|
.dbadge.err{color:var(--err);}
|
||||||
|
.mini-select{border:1px solid var(--line);border-radius:3px;padding:5px 7px;font-family:inherit;font-size:12px;background:#fff;}
|
||||||
|
.lightbox{position:fixed;inset:0;background:rgba(22,21,15,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:26px;z-index:50;}
|
||||||
|
.compare{position:relative;max-width:90vw;max-height:76vh;}
|
||||||
|
.compare img{max-width:90vw;max-height:70vh;display:block;border-radius:3px;}
|
||||||
|
.compare .over{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
|
||||||
|
.compare input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
|
||||||
|
.lightbox-foot{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
|
||||||
|
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||||
|
.pb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||||
|
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||||
|
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||||
|
`}</style>;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { filenameFromHeader } from '../lib/naming';
|
||||||
|
import { useT, type Dict, type Locale, type TFunction } from '../lib/useT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently printed.
|
||||||
|
*
|
||||||
|
* What is stored is **the instructions only** — sheet, sizes, quantities and the
|
||||||
|
* references to the images. No PDF: an A3 sheet is quickly 20–80 MB, and the
|
||||||
|
* module computes without AI, so the same job yields exactly the same sheet
|
||||||
|
* again. Generating it again is cheaper than keeping it.
|
||||||
|
*
|
||||||
|
* "Print again" therefore only works if **all** images came from the library and
|
||||||
|
* are still there. For uploaded files the source is gone after the clean-up —
|
||||||
|
* which is then also stated on the entry.
|
||||||
|
*/
|
||||||
|
interface Run {
|
||||||
|
id: string; origin: string; summary: string | null; paper: string | null;
|
||||||
|
pages: number; pieces: number; bytes: number | null; created_at: string;
|
||||||
|
images: { id: string; filename: string | null }[];
|
||||||
|
missing: number; uploaded: number; repeatable: boolean; config: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = (s: string, t: TFunction, dateLocale: string) => {
|
||||||
|
const d = new Date(s);
|
||||||
|
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||||
|
if (minutes < 1) return t('just now');
|
||||||
|
if (minutes < 60) return t('{n} min ago', { n: minutes });
|
||||||
|
if (minutes < 24 * 60) return t('{n} h ago', { n: Math.round(minutes / 60) });
|
||||||
|
return d.toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
};
|
||||||
|
// A test sheet is 20 KB, an A3 poster 80 MB. `toFixed(1)` in MB turned the small
|
||||||
|
// case into "0.0 MB" — so below one megabyte the size is given in KB.
|
||||||
|
const size = (b: number | null) => {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`;
|
||||||
|
return `${(b / 1048576).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
// Plural: pick the whole sentence, never glue a number to a word — which word
|
||||||
|
// form a number takes is a property of the language, not of the number.
|
||||||
|
const plural = (t: TFunction, n: number, one: string, many: string) => t(n === 1 ? one : many, { n });
|
||||||
|
|
||||||
|
export default function PrintRuns({ onLoad, locale, dict }: { onLoad?: (config: any) => void; locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
// Dates stay numeric (day/month/year); only the order and separators follow
|
||||||
|
// the language of the instance.
|
||||||
|
const dateLocale = locale === 'de' ? 'de-DE' : 'en-GB';
|
||||||
|
const [runs, setRuns] = useState<Run[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/print/runs');
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const d = await r.json();
|
||||||
|
setRuns(d.runs || []); setFailed(false);
|
||||||
|
} catch { setFailed(true); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const say = (text: string, ok = true) => { setMsg({ t: text, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||||
|
|
||||||
|
/** Produce the same sheet once more — same request, same PDF. */
|
||||||
|
const again = async (r: Run) => {
|
||||||
|
setBusy(r.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/print/sheet', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
// `origin` marks the run as a repeat, so the list does not look as if
|
||||||
|
// someone had built the same sheet twice by hand.
|
||||||
|
body: JSON.stringify({ ...r.config, origin: 'repeat' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
say(j.error || t('That did not work — an image may be missing.'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
// The repeat print is a **new** file with a new timestamp and a new short
|
||||||
|
// id — so it does not overwrite the first sheet.
|
||||||
|
a.download = filenameFromHeader(res.headers.get('Content-Disposition'), 'klarbild-print.pdf');
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||||
|
say(t('PDF generated again. When printing, choose “Actual size / 100 %”.'));
|
||||||
|
load();
|
||||||
|
} catch { say(t('Network error.'), false); }
|
||||||
|
finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (r: Run) => {
|
||||||
|
if (!confirm(t('Take this entry off the list?\n\nThe images stay in the library — only the history entry disappears.'))) return;
|
||||||
|
const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||||||
|
if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else say(res.error || t('That did not work.'), false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="pr-empty">{t('Loading …')}</p>;
|
||||||
|
|
||||||
|
if (failed) return (
|
||||||
|
<div className="pr">
|
||||||
|
<p className="pr-empty">{t('The history could not be loaded.')}{' '}
|
||||||
|
<button className="pr-btn" onClick={() => { setLoading(true); load(); }}>{t('Try again')}</button></p>
|
||||||
|
<PrintRunsStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!runs.length) return (
|
||||||
|
<div className="pr">
|
||||||
|
<div className="pr-empty-box">
|
||||||
|
<span className="regmark" />
|
||||||
|
<h2>{t('Nothing printed yet')}</h2>
|
||||||
|
<p>{t('As soon as you create a PDF in the print area, it stands here — with a button to get exactly the same sheet again.')}{' '}
|
||||||
|
<a href="/print">{t('Open the print area')}</a></p>
|
||||||
|
</div>
|
||||||
|
<PrintRunsStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pr">
|
||||||
|
{msg && <p className={`pr-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||||
|
|
||||||
|
<div className="pr-list">
|
||||||
|
{runs.map((r) => (
|
||||||
|
<article key={r.id} className="pr-card">
|
||||||
|
<div className="pr-head">
|
||||||
|
<div className="pr-title">
|
||||||
|
<h3>{r.summary || t('Print sheet')}</h3>
|
||||||
|
<span className="pr-when">{when(r.created_at, t, dateLocale)}</span>
|
||||||
|
</div>
|
||||||
|
{r.origin === 'repeat' && <span className="pr-mark">{t('repeated')}</span>}
|
||||||
|
{r.origin === 'telegram' && <span className="pr-mark">Telegram</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pr-meta">
|
||||||
|
{r.paper} · {plural(t, r.pages, '{n} sheet', '{n} sheets')} · {plural(t, r.pieces, '{n} piece', '{n} pieces')}
|
||||||
|
{r.bytes ? <> · {t('the PDF was {size}', { size: size(r.bytes) })}</> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.images.length > 0 && (
|
||||||
|
<div className="pr-images">
|
||||||
|
{r.images.slice(0, 8).map((b) => (
|
||||||
|
<img key={b.id} src={`/api/items/${b.id}/file?thumb=1`} alt={b.filename || ''}
|
||||||
|
title={b.filename || ''} loading="lazy"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
|
||||||
|
))}
|
||||||
|
{r.images.length > 8 && <span className="pr-more">+{r.images.length - 8}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pr-actions">
|
||||||
|
{r.repeatable ? (
|
||||||
|
<button className="pr-btn strong" disabled={busy === r.id} onClick={() => again(r)}>
|
||||||
|
{busy === r.id ? t('Being generated …') : t('Print again')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="pr-why">
|
||||||
|
{r.missing > 0
|
||||||
|
? plural(t, r.missing,
|
||||||
|
'{n} image has been deleted since — cannot be repeated.',
|
||||||
|
'{n} images have been deleted since — cannot be repeated.')
|
||||||
|
: t('Uploaded back then instead of taken from the library — the source file is gone.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onLoad && (
|
||||||
|
<button className="pr-btn" onClick={() => onLoad(r.config)}>{t('Apply these settings')}</button>
|
||||||
|
)}
|
||||||
|
<button className="pr-btn danger" onClick={() => remove(r)}>{t('Remove')}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="pr-hint">
|
||||||
|
{t('Only the settings are kept here, not the PDFs — an A3 sheet would quickly be 20 to 80 MB. Because the print module computes without AI, the same job yields exactly the same sheet again. The last {n} prints per person are kept.', { n: 100 })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PrintRunsStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrintRunsStyles() {
|
||||||
|
return <style>{`
|
||||||
|
.pr{display:grid;gap:14px;}
|
||||||
|
.pr-empty{color:var(--soft);}
|
||||||
|
.pr-empty-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
|
padding:44px 24px;text-align:center;}
|
||||||
|
.pr-empty-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||||||
|
.pr-empty-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||||||
|
.pr-list{display:grid;gap:12px;}
|
||||||
|
.pr-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
|
padding:15px;display:grid;gap:10px;}
|
||||||
|
.pr-head{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}
|
||||||
|
.pr-title{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;min-width:0;flex:1;}
|
||||||
|
.pr-title h3{margin:0;font-size:.98rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||||||
|
.pr-when{font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||||||
|
.pr-mark{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||||
|
padding:3px 8px;border-radius:20px;background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||||||
|
.pr-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||||
|
.pr-images{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||||||
|
.pr-images img{width:46px;height:46px;object-fit:cover;border:1px solid var(--line);border-radius:3px;
|
||||||
|
background:var(--paper);}
|
||||||
|
.pr-more{font-family:var(--font-mono);font-size:11px;color:var(--soft);}
|
||||||
|
.pr-actions{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||||||
|
.pr-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||||
|
padding:9px 12px;font-size:.84rem;cursor:pointer;font-family:inherit;color:var(--ink);white-space:nowrap;}
|
||||||
|
.pr-btn:hover{border-color:var(--accent);}
|
||||||
|
.pr-btn.strong{background:var(--accent);color:var(--card);border-color:var(--accent);font-weight:600;}
|
||||||
|
.pr-btn.danger{color:var(--err);}
|
||||||
|
.pr-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||||||
|
.pr-why{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||||
|
.pr-hint{margin:0;font-size:.78rem;color:var(--soft);line-height:1.6;max-width:70ch;}
|
||||||
|
.pr-msg{margin:0;font-size:.9rem;}
|
||||||
|
.pr-msg.ok{color:var(--ok);}
|
||||||
|
.pr-msg.err{color:var(--err);}
|
||||||
|
@media(max-width:640px){
|
||||||
|
.pr-btn{flex:1;text-align:center;padding:11px 10px;}
|
||||||
|
.pr-why{flex-basis:100%;}
|
||||||
|
}
|
||||||
|
`}</style>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
queued: 'waiting', running: 'running', done: 'done', failed: 'failed',
|
||||||
|
skipped: 'skipped', paused: 'paused', cancelled: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Point in time, short: today → "HH:MM", otherwise "DD.MM. HH:MM". */
|
||||||
|
function fmtTime(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const today = new Date();
|
||||||
|
const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
||||||
|
return sameDay ? hm : `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${hm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueueApp({ jobId, locale, dict }: { jobId: string | null; locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [job, setJob] = useState<any>(null);
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<any[]>([]);
|
||||||
|
const [saveView, setSaveView] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (jobId) {
|
||||||
|
const r = await fetch(`/api/jobs/${jobId}`).then((x) => x.json()).catch(() => null);
|
||||||
|
if (r?.job) { setJob(r.job); setItems(r.items || []); }
|
||||||
|
} else {
|
||||||
|
const r = await fetch('/api/jobs').then((x) => x.json()).catch(() => null);
|
||||||
|
setJobs(r?.jobs || []);
|
||||||
|
}
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const active = !job || ['queued', 'running', 'paused'].includes(job?.status);
|
||||||
|
const ti = setInterval(load, active ? 2000 : 6000);
|
||||||
|
return () => clearInterval(ti);
|
||||||
|
}, [load, job?.status]);
|
||||||
|
|
||||||
|
const act = async (path: string) => { await fetch(path, { method: 'POST' }); load(); };
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return (
|
||||||
|
<div className="q">
|
||||||
|
<div className="cardHead"><span className="mono-label">{t('Jobs')}</span></div>
|
||||||
|
{jobs.length === 0 ? <p className="hint">{t('No jobs yet. Start in the studio.')}</p> : (
|
||||||
|
<div className="joblist">
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<a key={j.id} className="jobrow" href={`/queue?job=${j.id}`}>
|
||||||
|
<span className={`dot ${j.status}`} />
|
||||||
|
<b>{j.done_count}/{j.total}</b>
|
||||||
|
<span className="hint">{t(STATUS_LABELS[j.status] || j.status)}{j.by_name ? ` · ${j.by_name}` : ''}
|
||||||
|
{j.finished_at && ['done', 'failed', 'cancelled'].includes(j.status) ? ` · ${t('done {time}', { time: fmtTime(j.finished_at) })}` : ''}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<QStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = items.filter((i) => i.status === 'done').length;
|
||||||
|
const failed = items.filter((i) => i.status === 'failed').length;
|
||||||
|
const open = items.filter((i) => ['queued', 'running'].includes(i.status)).length;
|
||||||
|
const pct = items.length ? Math.round(((done + failed) / items.length) * 100) : 0;
|
||||||
|
const etaSec = open * 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="q">
|
||||||
|
<div className="cardHead">
|
||||||
|
<span className="mono-label">{t('Job')} · {t(STATUS_LABELS[job?.status] || job?.status)}
|
||||||
|
{job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · ${t('ended {time}', { time: fmtTime(job.finished_at) })}` : ''}</span>
|
||||||
|
<div className="row">
|
||||||
|
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>{t('Pause')}</button>}
|
||||||
|
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>{t('Resume')}</button>}
|
||||||
|
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>{t('Retry the failed ones')}</button>}
|
||||||
|
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>{t('Cancel')}</button>}
|
||||||
|
{['done', 'failed', 'cancelled'].includes(job?.status) && (
|
||||||
|
<button className="mini" onClick={async () => {
|
||||||
|
if (!confirm(t('Delete this job?'))) return;
|
||||||
|
await fetch(`/api/jobs/${jobId}/delete`, { method: 'POST' });
|
||||||
|
location.href = '/queue';
|
||||||
|
}}>{t('Delete')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="progress">
|
||||||
|
<div className="bar"><i style={{ width: `${pct}%` }} /></div>
|
||||||
|
<div className="hint">{failed
|
||||||
|
? t('{done} done, {failed} failed of {total}', { done, failed, total: items.length })
|
||||||
|
: t('{done} done of {total}', { done, total: items.length })}
|
||||||
|
{open > 0 && ` · ${etaSec >= 60
|
||||||
|
? t('about {n} more min', { n: Math.ceil(etaSec / 60) })
|
||||||
|
: t('about {n} more sec', { n: etaSec })}`}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="items">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.id} className={`item ${it.status}`}>
|
||||||
|
<div className="thumb">
|
||||||
|
{it.status === 'done' && it.result_path
|
||||||
|
? <img src={`/api/items/${it.id}/file`} alt="" loading="lazy" />
|
||||||
|
: <span className={`dot ${it.status}`} />}
|
||||||
|
</div>
|
||||||
|
<div className="itxt">
|
||||||
|
<b>{it.filename || t('Image {n}', { n: it.position + 1 })}</b>
|
||||||
|
<span className="hint">{STATUS_LABELS[it.status] ? t(STATUS_LABELS[it.status]) : ''}{it.output_px ? ` · ${it.output_px}px` : ''}</span>
|
||||||
|
{it.error_message && <span className="hint err">{it.error_message}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="iact">
|
||||||
|
{it.status === 'done' && <button className="mini" onClick={() => setSaveView(it)}>{t('Download')}</button>}
|
||||||
|
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>{t('Again')}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{saveView && (
|
||||||
|
<div className="zoom" onClick={() => setSaveView(null)}>
|
||||||
|
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||||
|
<div className="save-foot" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span>{t('📱 iPhone/iPad: press and hold the image → “Save to Photos”.')}</span>
|
||||||
|
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>{t('Original as a file (.png)')}</a>
|
||||||
|
<button className="mini" onClick={() => setSaveView(null)}>{t('Close')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<QStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QStyles() {
|
||||||
|
return <style>{`
|
||||||
|
.q{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||||
|
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);gap:10px;flex-wrap:wrap;}
|
||||||
|
.row{display:flex;gap:6px;flex-wrap:wrap;}
|
||||||
|
.progress{padding:16px;border-bottom:1px solid var(--line);}
|
||||||
|
.bar{height:6px;background:var(--paper);border-radius:20px;overflow:hidden;}
|
||||||
|
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s;}
|
||||||
|
.hint{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;display:block;}
|
||||||
|
.hint.err{color:var(--err);}
|
||||||
|
.items{display:flex;flex-direction:column;}
|
||||||
|
.item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);}
|
||||||
|
.thumb{width:46px;height:46px;flex:none;border:1px solid var(--line);border-radius:3px;overflow:hidden;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/12px 12px;}
|
||||||
|
.thumb img{width:100%;height:100%;object-fit:contain;}
|
||||||
|
.itxt{flex:1;min-width:0;}
|
||||||
|
.itxt b{font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.iact{flex:none;}
|
||||||
|
.mini{background:#fff;border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||||
|
.dot{width:11px;height:11px;border-radius:50%;background:var(--mark);display:inline-block;}
|
||||||
|
.dot.running{background:var(--accent);}
|
||||||
|
.dot.done{background:var(--ok);}
|
||||||
|
.dot.failed{background:var(--err);}
|
||||||
|
.dot.queued{background:var(--mark);}
|
||||||
|
.zoom{position:fixed;inset:0;background:rgba(22,21,15,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:20px;z-index:50;}
|
||||||
|
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||||
|
.save-foot{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;}
|
||||||
|
.save-foot .mini{text-decoration:none;}
|
||||||
|
.joblist{display:flex;flex-direction:column;}
|
||||||
|
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
|
||||||
|
p.hint{padding:16px;}
|
||||||
|
`}</style>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share dialog: turn a picture selection into a link and hand it on.
|
||||||
|
*
|
||||||
|
* Deliberately in two steps — settings first, then handing it on. The link comes
|
||||||
|
* into being on the click on "Create link"; until then nothing is public. After
|
||||||
|
* that the same dialog shows the routes: copy, WhatsApp, email.
|
||||||
|
*/
|
||||||
|
export interface ShareTarget { itemIds: string[]; title?: string }
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ v: 7, label: '7 days' },
|
||||||
|
{ v: 30, label: '30 days' },
|
||||||
|
{ v: 90, label: '3 months' },
|
||||||
|
{ v: 365, label: '1 year' },
|
||||||
|
{ v: 0, label: 'no expiry' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ShareDialog(
|
||||||
|
{ target, onClose, onDone, locale, dict }:
|
||||||
|
{ target: ShareTarget; onClose: () => void; onDone?: () => void; locale?: Locale; dict?: Dict | null },
|
||||||
|
) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const many = target.itemIds.length > 1;
|
||||||
|
const [title, setTitle] = useState(target.title || '');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [download, setDownload] = useState(true);
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||||
|
const [mailTo, setMailTo] = useState('');
|
||||||
|
const [mailText, setMailText] = useState('');
|
||||||
|
const [mailReady, setMailReady] = useState<boolean | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const url = link ? `${location.origin}/s/${link.slug}` : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/me').then((r) => r.json()).then((m) => {
|
||||||
|
setMailReady(!!m.mail_ready);
|
||||||
|
// Take over the default from the admin area — otherwise the setting there
|
||||||
|
// promises something that never arrives here.
|
||||||
|
if (typeof m.share_default_days === 'number') setDays(m.share_default_days);
|
||||||
|
}).catch(() => setMailReady(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Trap the focus and hold the background still while the dialog is open.
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
box.current?.querySelector<HTMLElement>('input,select,button')?.focus();
|
||||||
|
const key = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const f = box.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input,select,textarea');
|
||||||
|
if (!f?.length) return;
|
||||||
|
const [first, last] = [f[0], f[f.length - 1]];
|
||||||
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', key);
|
||||||
|
return () => { document.removeEventListener('keydown', key); document.body.style.overflow = previous; };
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true); setMsg(null);
|
||||||
|
const r = await fetch('/api/shares', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemIds: target.itemIds, title: title || null, note: note || null,
|
||||||
|
days, allowDownload: download, password: passphrase || null,
|
||||||
|
}),
|
||||||
|
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||||
|
setBusy(false);
|
||||||
|
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
|
||||||
|
setLink({ slug: r.share.slug, id: r.share.id });
|
||||||
|
onDone?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(url); }
|
||||||
|
catch {
|
||||||
|
// Without clipboard permission: select the field so it can be copied by hand.
|
||||||
|
box.current?.querySelector<HTMLInputElement>('.linkfield')?.select();
|
||||||
|
}
|
||||||
|
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waText = `${title || t('Images')}\n${url}`;
|
||||||
|
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
|
||||||
|
|
||||||
|
const sendMail = async () => {
|
||||||
|
if (!mailTo.trim()) { setMsg({ t: t('Enter a recipient address.'), ok: false }); return; }
|
||||||
|
setBusy(true); setMsg(null);
|
||||||
|
const r = await fetch('/api/shares/send', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shareId: link!.id, to: mailTo, message: mailText }),
|
||||||
|
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||||
|
setBusy(false);
|
||||||
|
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
|
||||||
|
// Partial success is success: before, a text pattern match coloured
|
||||||
|
// "2 sent, 1 did not go through." entirely red.
|
||||||
|
setMsg(r.failed?.length
|
||||||
|
? { t: t('{sent} sent, {failed} did not go through.', { sent: r.ok, failed: r.failed.length }), ok: r.ok > 0 }
|
||||||
|
: { t: r.ok === 1 ? t('Sent to the address.') : t('Sent to {n} addresses.', { n: r.ok }), ok: true });
|
||||||
|
setMailTo('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two sentences, one key, with the passphrase kept in bold: the {passphrase}
|
||||||
|
// slot is split out of the translated sentence so the word lands where the
|
||||||
|
// language puts it instead of the sentence being glued from two halves.
|
||||||
|
const warn = t('The link is protected with {passphrase}. Send the passphrase separately from the link — otherwise the protection is worth nothing.')
|
||||||
|
.split('{passphrase}');
|
||||||
|
const mailHint = t('Sending email straight from here is not set up yet — an admin can add that under {where}. Copying and WhatsApp work regardless.')
|
||||||
|
.split('{where}');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tl-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="tl-box" ref={box} role="dialog" aria-modal="true" aria-label={t('Share')}>
|
||||||
|
<div className="tl-head">
|
||||||
|
<h2>{many ? t('Share {n} images', { n: target.itemIds.length }) : t('Share image')}</h2>
|
||||||
|
<button className="tl-x" onClick={onClose} aria-label={t('Close')}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!link ? (
|
||||||
|
<div className="tl-body">
|
||||||
|
<p className="tl-lead">
|
||||||
|
{t('This creates a link that someone without a Klarbild account can open too. It expires by itself and can be switched off again at any time.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="tl-field">{t('Heading')}
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={many ? t('e.g. Nursery photos August') : t('e.g. Ada in the garden')} maxLength={120} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="tl-field">{t('Valid for')}
|
||||||
|
<select value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||||
|
{DAYS.map((d) => <option key={d.v} value={d.v}>{t(d.label)}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* The common case is "one picture for grandma" — that one needs none of
|
||||||
|
this. Folded away, with a summary that names the current state. */}
|
||||||
|
<details className="tl-more">
|
||||||
|
<summary>
|
||||||
|
<span>{t('More settings')}</span>
|
||||||
|
<em>{download ? t('download allowed') : t('view only')}
|
||||||
|
{' · '}{passphrase ? t('with passphrase') : t('without passphrase')}</em>
|
||||||
|
</summary>
|
||||||
|
<div className="tl-more-body">
|
||||||
|
<label className="tl-field">{t('Message on the page')}
|
||||||
|
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2}
|
||||||
|
placeholder={t('Shown above the pictures')} maxLength={500} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="tl-check">
|
||||||
|
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
|
||||||
|
<span><b>{t('Allow downloading')}</b><em>{t('Without the tick the pictures can only be viewed.')}</em></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="tl-field">{t('Passphrase')}
|
||||||
|
<input type="text" value={passphrase} onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder={t('Empty = everyone with the link gets in')} maxLength={100} />
|
||||||
|
<span className="tl-hint">{t('Send the passphrase by a different route than the link.')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||||
|
<div className="tl-foot">
|
||||||
|
<button className="tl-secondary" onClick={onClose}>{t('Cancel')}</button>
|
||||||
|
<button className="tl-primary" onClick={create} disabled={busy}>
|
||||||
|
{busy ? t('One moment …') : t('Create link')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tl-body">
|
||||||
|
<p className="tl-lead">{t('The link is there. This is how it reaches the recipient:')}</p>
|
||||||
|
|
||||||
|
<div className="tl-linkrow">
|
||||||
|
<input className="linkfield" value={url} readOnly onFocus={(e) => e.currentTarget.select()} aria-label={t('Share link')} />
|
||||||
|
<button className="tl-primary" onClick={copy}>{copied ? t('Copied ✓') : t('Copy')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tl-ways">
|
||||||
|
<a className="tl-way wa" href={waHref} target="_blank" rel="noopener">
|
||||||
|
<span className="tl-way-title">WhatsApp</span>
|
||||||
|
<span className="tl-way-hint">{t('Opens WhatsApp with the text ready')}</span>
|
||||||
|
</a>
|
||||||
|
<a className="tl-way" href={`mailto:?subject=${encodeURIComponent(title || t('Images'))}&body=${encodeURIComponent(waText)}`}>
|
||||||
|
<span className="tl-way-title">{t('Your own mail program')}</span>
|
||||||
|
<span className="tl-way-hint">{t('Opens your mail app')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passphrase && (
|
||||||
|
<p className="tl-warn">
|
||||||
|
{warn[0]}<b>{passphrase}</b>{warn[1]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tl-divider"><span>{t('or send it straight from here')}</span></div>
|
||||||
|
|
||||||
|
{mailReady === null ? null : mailReady ? (
|
||||||
|
<>
|
||||||
|
<label className="tl-field">{t('To')}
|
||||||
|
<input value={mailTo} onChange={(e) => setMailTo(e.target.value)} type="email"
|
||||||
|
placeholder={t('grandma@example.com, grandpa@example.com')} />
|
||||||
|
<span className="tl-hint">{t('Separate several addresses with a comma.')}</span>
|
||||||
|
</label>
|
||||||
|
<label className="tl-field"><span className="tl-fieldhead">{t('Add a note')} <em>{t('optional')}</em></span>
|
||||||
|
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
|
||||||
|
placeholder={t('A few lines for the recipient')} maxLength={1000} />
|
||||||
|
</label>
|
||||||
|
<button className="tl-secondary wide" onClick={sendMail} disabled={busy}>
|
||||||
|
{busy ? t('Sending …') : t('Send by email')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="tl-hint">
|
||||||
|
{mailHint[0]}<b>{t('Admin → Email')}</b>{mailHint[1]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||||
|
|
||||||
|
<div className="tl-foot">
|
||||||
|
<a className="tl-secondary" href="/shares">{t('Manage all links')}</a>
|
||||||
|
<button className="tl-secondary" onClick={onClose}>{t('Done')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.tl-backdrop{position:fixed;inset:0;z-index:80;background:rgba(22,21,15,.55);
|
||||||
|
display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);}
|
||||||
|
.tl-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
|
width:min(520px,100%);max-height:min(88vh,760px);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(22,21,15,.25);}
|
||||||
|
.tl-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
||||||
|
padding:16px 18px;border-bottom:1px solid var(--line);}
|
||||||
|
.tl-head h2{margin:0;font-size:1.05rem;letter-spacing:-.01em;}
|
||||||
|
.tl-x{border:none;background:none;font-size:17px;cursor:pointer;color:var(--soft);
|
||||||
|
width:34px;height:34px;border-radius:50%;line-height:1;}
|
||||||
|
.tl-x:hover{background:var(--paper);color:var(--ink);}
|
||||||
|
.tl-body{padding:18px;overflow:auto;display:grid;gap:14px;}
|
||||||
|
.tl-lead{margin:0;font-size:.9rem;color:var(--ink-soft);line-height:1.6;}
|
||||||
|
.tl-field{display:grid;gap:6px;font-size:.86rem;color:var(--ink-soft);}
|
||||||
|
.tl-field input,.tl-field select,.tl-field textarea{padding:10px 11px;border:1px solid var(--line);
|
||||||
|
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;
|
||||||
|
font-family:inherit;width:100%;box-sizing:border-box;}
|
||||||
|
.tl-field textarea{resize:vertical;line-height:1.5;}
|
||||||
|
.tl-fieldhead{display:flex;gap:6px;align-items:baseline;}
|
||||||
|
.tl-fieldhead em{font-style:normal;color:var(--soft);font-size:.78rem;}
|
||||||
|
.tl-hint{font-size:.78rem;color:var(--soft);line-height:1.5;}
|
||||||
|
.tl-check{display:flex;gap:10px;align-items:flex-start;font-size:.88rem;cursor:pointer;}
|
||||||
|
.tl-check input{width:18px;height:18px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||||
|
.tl-check b{display:block;font-weight:600;color:var(--ink);}
|
||||||
|
.tl-check em{display:block;font-style:normal;color:var(--soft);font-size:.8rem;margin-top:2px;}
|
||||||
|
.tl-linkrow{display:flex;gap:8px;}
|
||||||
|
.tl-linkrow input{flex:1;min-width:0;padding:10px 11px;border:1px solid var(--line);
|
||||||
|
border-radius:var(--radius-sm);background:var(--paper);font-family:var(--font-mono);
|
||||||
|
font-size:12.5px;color:var(--ink);}
|
||||||
|
.tl-primary{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
|
||||||
|
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||||
|
.tl-primary.wide,.tl-secondary.wide{width:100%;}
|
||||||
|
.tl-primary:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
|
||||||
|
.tl-secondary{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||||
|
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
|
||||||
|
text-decoration:none;display:inline-block;text-align:center;}
|
||||||
|
.tl-ways{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||||||
|
.tl-way{display:grid;gap:2px;padding:11px 13px;border:1px solid var(--line);
|
||||||
|
border-radius:var(--radius-sm);background:var(--paper);text-decoration:none;color:var(--ink);}
|
||||||
|
.tl-way:hover{border-color:var(--accent);}
|
||||||
|
.tl-way.wa{border-color:#25D366;}
|
||||||
|
.tl-way-title{font-weight:600;font-size:.88rem;}
|
||||||
|
.tl-way-hint{font-size:.75rem;color:var(--soft);line-height:1.4;}
|
||||||
|
.tl-more{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);}
|
||||||
|
.tl-more>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline;
|
||||||
|
justify-content:space-between;gap:10px;padding:12px;font-size:.88rem;}
|
||||||
|
.tl-more>summary>span{white-space:nowrap;}
|
||||||
|
.tl-more>summary::-webkit-details-marker{display:none;}
|
||||||
|
.tl-more>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;}
|
||||||
|
.tl-more[open]>summary::after{transform:rotate(180deg);}
|
||||||
|
.tl-more>summary em{font-style:normal;font-size:.76rem;color:var(--soft);
|
||||||
|
margin-left:auto;text-align:right;line-height:1.4;}
|
||||||
|
.tl-more-body{display:grid;gap:12px;padding:0 12px 12px;}
|
||||||
|
.tl-divider{display:flex;align-items:center;gap:10px;color:var(--soft);
|
||||||
|
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
|
||||||
|
.tl-divider::before,.tl-divider::after{content:'';flex:1;height:1px;background:var(--line);}
|
||||||
|
.tl-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||||
|
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||||
|
.tl-msg{margin:0;font-size:.86rem;line-height:1.5;}
|
||||||
|
.tl-msg.err{color:var(--err);}
|
||||||
|
.tl-msg.ok{color:var(--ok);}
|
||||||
|
.tl-foot{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
|
||||||
|
@media(max-width:560px){
|
||||||
|
.tl-backdrop{padding:0;align-items:flex-end;}
|
||||||
|
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;
|
||||||
|
padding-bottom:env(safe-area-inset-bottom);}
|
||||||
|
.tl-ways{grid-template-columns:1fr;}
|
||||||
|
.tl-foot{flex-direction:column-reverse;}
|
||||||
|
.tl-foot>*{width:100%;}
|
||||||
|
.tl-primary,.tl-secondary{padding:13px 16px;}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managing the share links: see how often a link was called up, change it,
|
||||||
|
* switch it off or delete it.
|
||||||
|
*
|
||||||
|
* The counter is deliberately plain: views, downloads and the number of
|
||||||
|
* distinct visitors. Names or addresses are nowhere to be found: what is
|
||||||
|
* stored is a hash of the IP, salted with the server secret **and** with the
|
||||||
|
* identifier of the link. That makes it possible to recognise the same visitor
|
||||||
|
* within one link, but not to follow them across several links.
|
||||||
|
* Named honestly, that is pseudonymisation, not anonymisation — whoever has the
|
||||||
|
* server key could compute their way through the IPv4 space. Which is also why
|
||||||
|
* it is only kept for 90 days.
|
||||||
|
*/
|
||||||
|
interface Share {
|
||||||
|
id: string; slug: string; kind: string; title: string | null; note: string | null;
|
||||||
|
expires_at: string | null; revoked_at: string | null; allow_download: boolean;
|
||||||
|
password_hash: string | null; views: number; downloads: number;
|
||||||
|
last_seen_at: string | null; created_at: string; count: number; visitor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ v: 7, label: '7 days from now' }, { v: 30, label: '30 days from now' },
|
||||||
|
{ v: 90, label: '3 months from now' }, { v: 365, label: '1 year from now' }, { v: 0, label: 'no expiry' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const date = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
|
||||||
|
const time = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
|
||||||
|
type State = 'active' | 'expired' | 'off';
|
||||||
|
const state = (s: Share): State =>
|
||||||
|
s.revoked_at ? 'off'
|
||||||
|
: s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'expired'
|
||||||
|
: 'active';
|
||||||
|
|
||||||
|
export default function SharesApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [shares, setShares] = useState<Share[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
|
const [visits, setVisits] = useState<Record<string, any[]>>({});
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
// A network error must not look like "nothing shared" — otherwise one thinks
|
||||||
|
// one's own links are gone.
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/shares');
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const d = await r.json();
|
||||||
|
setShares(d.shares || []); setLoadError(false);
|
||||||
|
} catch { setLoadError(true); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const say = (text: string) => { setMsg(text); setTimeout(() => setMsg(null), 4000); };
|
||||||
|
|
||||||
|
const patch = async (id: string, body: unknown) => {
|
||||||
|
const r = await fetch(`/api/shares/${id}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||||
|
// `{ok:false}` means: the link belongs to someone else or was already in that
|
||||||
|
// state. Without feedback the button seems broken.
|
||||||
|
if (r.error) say(r.error);
|
||||||
|
else if (r.ok === false) { say(t('Nothing to change — perhaps already done?')); await load(); }
|
||||||
|
else await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (s: Share) => {
|
||||||
|
if (!confirm(t('Delete the link "{name}" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.',
|
||||||
|
{ name: s.title || s.slug }))) return;
|
||||||
|
const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||||||
|
if (r.ok) { say(t('Link deleted.')); await load(); } else say(r.error || t('That did not work.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (s: Share) => {
|
||||||
|
const url = `${location.origin}/s/${s.slug}`;
|
||||||
|
try { await navigator.clipboard.writeText(url); } catch { prompt(t('Copy link:'), url); }
|
||||||
|
setCopied(s.id); setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = async (s: Share) => {
|
||||||
|
if (open === s.id) { setOpen(null); return; }
|
||||||
|
setOpen(s.id);
|
||||||
|
// Load again on every unfolding — otherwise the list shows an old state the
|
||||||
|
// second time round without anyone noticing.
|
||||||
|
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ visits: [] }));
|
||||||
|
setVisits((z) => ({ ...z, [s.id]: r.visits || [] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="tw-empty">{t('One moment …')}</p>;
|
||||||
|
|
||||||
|
// One sentence, one key, with the link and the emphasis kept: the slots are
|
||||||
|
// split out of the translated sentence so both land where the language puts
|
||||||
|
// them instead of the sentence being glued from three fragments.
|
||||||
|
const emptyParts = t('Tap {share} in the {library} — then an entry appears here that you can switch off again at any time.')
|
||||||
|
.split(/(\{share\}|\{library\})/);
|
||||||
|
const privacyParts = t('What is recorded is {when} it was opened and with what kind of device — not {who}. Of the IP address only an encrypted fingerprint is stored, which is moreover tied to this one link; across several links nobody can be followed with it. After 90 days everything is deleted.')
|
||||||
|
.split(/(\{when\}|\{who\})/);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tw">
|
||||||
|
{msg && <div className="tw-toast" role="status">{msg}</div>}
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="tw-empty-box">
|
||||||
|
<span className="regmark" />
|
||||||
|
<h2>{t('The list could not be loaded')}</h2>
|
||||||
|
<p>{t('Maybe the connection was briefly gone. Your links are not affected by it.')}</p>
|
||||||
|
<p><button className="tw-btn strong" onClick={() => { setLoading(true); load(); }}>{t('Try again')}</button></p>
|
||||||
|
</div>
|
||||||
|
) : !shares.length ? (
|
||||||
|
<div className="tw-empty-box">
|
||||||
|
<span className="regmark" />
|
||||||
|
<h2>{t('Nothing shared yet')}</h2>
|
||||||
|
<p>{emptyParts.map((p, i) =>
|
||||||
|
p === '{share}' ? <b key={i}>{t('Share')}</b>
|
||||||
|
: p === '{library}' ? <a key={i} href="/library">{t('library')}</a>
|
||||||
|
: p)}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tw-list">
|
||||||
|
{shares.map((s) => {
|
||||||
|
const z = state(s);
|
||||||
|
return (
|
||||||
|
<article key={s.id} className={`tw-card ${z}`}>
|
||||||
|
<div className="tw-head">
|
||||||
|
<div className="tw-title">
|
||||||
|
<h3>{s.title || (s.count > 1 ? t('{n} images', { n: s.count }) : t('One image'))}</h3>
|
||||||
|
<span className={`tw-status ${z}`}>
|
||||||
|
{z === 'active' ? t('active') : z === 'off' ? t('switched off') : t('expired')}
|
||||||
|
</span>
|
||||||
|
{s.password_hash && <span className="tw-protected" title={t('Protected with a passphrase')}>{t('protected')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="tw-counts">
|
||||||
|
<span><b>{s.views}</b> {t('views')}</span>
|
||||||
|
<span><b>{s.downloads}</b> {t('downloads')}</span>
|
||||||
|
<span><b>{s.visitor}</b> {s.visitor === 1 ? t('visitor') : t('visitors')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tw-meta">
|
||||||
|
{s.count === 1 ? t('{n} image', { n: s.count }) : t('{n} images', { n: s.count })} ·
|
||||||
|
{' '}{s.expires_at ? t('valid until {date}', { date: date(s.expires_at) }) : t('without expiry')} ·
|
||||||
|
{' '}{t('created {date}', { date: date(s.created_at) })}
|
||||||
|
{s.last_seen_at && <> · {t('last seen {time}', { time: time(s.last_seen_at) })}</>}
|
||||||
|
{!s.allow_download && <> · {t('view only')}</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tw-row">
|
||||||
|
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
|
||||||
|
onFocus={(e) => e.currentTarget.select()} aria-label={t('Share link')} />
|
||||||
|
<button className="tw-btn" onClick={() => copy(s)}>{copied === s.id ? t('Copied ✓') : t('Copy')}</button>
|
||||||
|
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">{t('Open')}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tw-actions">
|
||||||
|
{z === 'off'
|
||||||
|
? <button className="tw-btn strong" onClick={() => patch(s.id, { action: 'unrevoke' })}>{t('Switch back on')}</button>
|
||||||
|
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>{t('Switch off')}</button>}
|
||||||
|
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
|
||||||
|
aria-label={t('Change validity')}>
|
||||||
|
<option value="" disabled>{t('Change validity …')}</option>
|
||||||
|
{DAYS.map((d) => <option key={d.v} value={d.v}>{t(d.label)}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
|
||||||
|
{s.allow_download ? t('Block download') : t('Allow download')}
|
||||||
|
</button>
|
||||||
|
<button className="tw-btn" onClick={() => {
|
||||||
|
const w = prompt(s.password_hash
|
||||||
|
? t('New passphrase (leave empty = remove protection):')
|
||||||
|
: t('Set passphrase:'));
|
||||||
|
if (w !== null) patch(s.id, { password: w });
|
||||||
|
}}>{s.password_hash ? t('Change passphrase') : t('Set passphrase')}</button>
|
||||||
|
<button className="tw-btn" onClick={() => details(s)}>{open === s.id ? t('Hide visits') : t('Visits')}</button>
|
||||||
|
<button className="tw-btn danger" onClick={() => remove(s)}>{t('Delete')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open === s.id && (
|
||||||
|
<div className="tw-visits">
|
||||||
|
{!visits[s.id]?.length ? <p className="tw-hint">{t('No visits yet.')}</p> : (
|
||||||
|
<ul>
|
||||||
|
{visits[s.id].slice(0, 30).map((v, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="tw-kind">{v.kind === 'download' ? t('Download') : t('View')}</span>
|
||||||
|
<span className="tw-when">{time(v.at)}</span>
|
||||||
|
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="tw-hint">{privacyParts.map((p, i) =>
|
||||||
|
p === '{when}' ? <b key={i}>{t('when')}</b>
|
||||||
|
: p === '{who}' ? <b key={i}>{t('who')}</b>
|
||||||
|
: p)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.tw{display:grid;gap:14px;}
|
||||||
|
.tw-empty{color:var(--soft);}
|
||||||
|
.tw-empty-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
|
padding:44px 24px;text-align:center;}
|
||||||
|
.tw-empty-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||||||
|
.tw-empty-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||||||
|
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
|
||||||
|
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
|
||||||
|
.tw-list{display:grid;gap:12px;}
|
||||||
|
.tw-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
|
padding:16px;display:grid;gap:11px;}
|
||||||
|
.tw-card.off,.tw-card.expired{background:var(--paper);}
|
||||||
|
.tw-head{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
|
||||||
|
.tw-title{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
|
||||||
|
.tw-title h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||||||
|
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||||
|
padding:3px 8px;border-radius:20px;}
|
||||||
|
.tw-status.active{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
|
||||||
|
.tw-status.off{background:oklch(55% .19 25 / .12);color:var(--err);}
|
||||||
|
.tw-status.expired{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||||||
|
.tw-protected{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||||
|
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
|
||||||
|
.tw-counts{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||||||
|
.tw-counts b{color:var(--ink);font-size:.95rem;}
|
||||||
|
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||||
|
.tw-row{display:flex;gap:7px;}
|
||||||
|
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||||
|
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
|
||||||
|
.tw-actions{display:flex;flex-wrap:wrap;gap:6px;}
|
||||||
|
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||||
|
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
|
||||||
|
text-decoration:none;display:inline-block;white-space:nowrap;}
|
||||||
|
.tw-btn:hover{border-color:var(--accent);}
|
||||||
|
.tw-btn.strong{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||||
|
.tw-btn.danger{color:var(--err);}
|
||||||
|
.tw-visits{border-top:1px solid var(--line);padding-top:11px;}
|
||||||
|
.tw-visits ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
|
||||||
|
.tw-visits li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
|
||||||
|
font-size:.76rem;align-items:baseline;}
|
||||||
|
.tw-kind{font-weight:600;}
|
||||||
|
.tw-when{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
|
||||||
|
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.tw-hint{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
|
||||||
|
@media(max-width:640px){
|
||||||
|
.tw-head{flex-direction:column;gap:8px;}
|
||||||
|
.tw-counts{gap:12px;}
|
||||||
|
.tw-row{flex-wrap:wrap;}
|
||||||
|
.tw-link{flex-basis:100%;}
|
||||||
|
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
|
||||||
|
.tw-visits li{grid-template-columns:74px 1fr;}
|
||||||
|
.tw-ua{display:none;}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||||
|
|
||||||
|
/* Formats (01 §5) — cm in portrait convention; screen = fixed pixels */
|
||||||
|
const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [number, number] }[] = [
|
||||||
|
{ id: 'keep', label: 'Keep original' },
|
||||||
|
{ id: '9x13', label: '9 × 13 cm', cm: [9, 13] },
|
||||||
|
{ id: '10x15', label: '10 × 15 cm', cm: [10, 15] },
|
||||||
|
{ id: '13x18', label: '13 × 18 cm', cm: [13, 18] },
|
||||||
|
{ id: '15x20', label: '15 × 20 cm', cm: [15, 20] },
|
||||||
|
{ id: '20x30', label: '20 × 30 cm', cm: [20, 30] },
|
||||||
|
{ id: '30x40', label: '30 × 40 cm', cm: [30, 40] },
|
||||||
|
{ id: '30x45', label: '30 × 45 cm', cm: [30, 45] },
|
||||||
|
{ id: '40x50', label: '40 × 50 cm', cm: [40, 50] },
|
||||||
|
{ id: '40x60', label: '40 × 60 cm', cm: [40, 60] },
|
||||||
|
{ id: '50x70', label: '50 × 70 cm', cm: [50, 70] },
|
||||||
|
{ id: '60x90', label: '60 × 90 cm', cm: [60, 90] },
|
||||||
|
{ id: 'A4', label: 'DIN A4', cm: [21, 29.7] },
|
||||||
|
{ id: 'A3', label: 'DIN A3', cm: [29.7, 42] },
|
||||||
|
{ id: 'A2', label: 'DIN A2', cm: [42, 59.4] },
|
||||||
|
{ id: '20x20', label: '20 × 20 cm', cm: [20, 20] },
|
||||||
|
{ id: '30x30', label: '30 × 30 cm', cm: [30, 30] },
|
||||||
|
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
|
||||||
|
{ id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] },
|
||||||
|
{ id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] },
|
||||||
|
{ id: 'custom', label: 'Custom size (cm)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Free-text size → format key + cm. "25x35" → 25×35 cm; "5" → 5×5 cm sticker. */
|
||||||
|
function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } {
|
||||||
|
const v = (s || '').trim().replace(',', '.');
|
||||||
|
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(v);
|
||||||
|
if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] };
|
||||||
|
const n = /^(\d+(?:\.\d+)?)$/.exec(v);
|
||||||
|
if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] };
|
||||||
|
return { key: '', cm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASKS = [
|
||||||
|
{ id: 'clean', name: 'Clean up', hint: 'Remove frame, wall and shop interface.' },
|
||||||
|
{ id: 'cutout', name: 'Cut out', hint: 'Subject only, background transparent.' },
|
||||||
|
{ id: 'format', name: 'Format', hint: 'Bring it to a given size.' },
|
||||||
|
{ id: 'contour', name: 'Contour', hint: 'Add a white sticker border (only together with Cut out).' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Mode = 'each' | 'compose' | 'generate';
|
||||||
|
const MODES: { id: Mode; name: string; hint: string }[] = [
|
||||||
|
{ id: 'each', name: 'Clean up', hint: 'Clean up screenshots, cut them out, bring them to size.' },
|
||||||
|
{ id: 'compose', name: 'Convert', hint: 'Convert one image (e.g. photo → oil painting) or combine several — with text.' },
|
||||||
|
{ id: 'generate', name: 'Generate', hint: 'A completely new image from text alone.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
|
||||||
|
|
||||||
|
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
|
||||||
|
|
||||||
|
export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; locale?: Locale; dict?: Dict | null }) {
|
||||||
|
const t = useT(dict);
|
||||||
|
const [presets, setPresets] = useState<any[]>(recipes || []);
|
||||||
|
const [mode, setMode] = useState<Mode>('each');
|
||||||
|
const [pics, setPics] = useState<Pic[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
||||||
|
const [format, setFormat] = useState('30x40');
|
||||||
|
const [portrait, setPortrait] = useState(true);
|
||||||
|
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
|
||||||
|
const [contourMm, setContourMm] = useState(3);
|
||||||
|
const [customFmt, setCustomFmt] = useState(''); // free-text size for "Custom size"/"Sticker"
|
||||||
|
const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = the global default setting
|
||||||
|
const [custom, setCustom] = useState('');
|
||||||
|
const [desc, setDesc] = useState('');
|
||||||
|
const [delivery, setDelivery] = useState<'library' | 'remote' | 'both'>('library');
|
||||||
|
const [gallery, setGallery] = useState('');
|
||||||
|
const [targetId, setTargetId] = useState('');
|
||||||
|
const [targets, setTargets] = useState<any[]>([]);
|
||||||
|
const [presetSel, setPresetSel] = useState('');
|
||||||
|
const [me, setMe] = useState<any>({});
|
||||||
|
const [priv, setPriv] = useState(false);
|
||||||
|
const [models, setModels] = useState<any[]>([]);
|
||||||
|
const [modelKey, setModelKey] = useState<string>('');
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fmt = FORMATS.find((f) => f.id === format);
|
||||||
|
const hasCutout = tasks.includes('cutout');
|
||||||
|
const hasFormat = tasks.includes('format');
|
||||||
|
const theframeConflict = hasCutout && (format === 'theframe' || format === 'portrait916');
|
||||||
|
// In compose/generate the size is always available (without the "Format" task).
|
||||||
|
const showFormat = mode === 'each' ? hasFormat : true;
|
||||||
|
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
||||||
|
|
||||||
|
const isFreeFmt = format === 'custom' || format === 'sticker';
|
||||||
|
const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null };
|
||||||
|
// "Sticker" without an entry still means 5×5 cm; "Custom size" needs an entry.
|
||||||
|
const effCm: [number, number] | null = fmt?.screen ? null
|
||||||
|
: parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null));
|
||||||
|
// What is actually stored as output_format (e.g. "25x35", "sticker5").
|
||||||
|
const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format;
|
||||||
|
|
||||||
|
const targetPx: [number, number] | null = fmt?.screen
|
||||||
|
? (fmt.screen as [number, number])
|
||||||
|
: effCm
|
||||||
|
? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2600); };
|
||||||
|
|
||||||
|
const upload = useCallback(async (files: FileList | File[]) => {
|
||||||
|
const arr = Array.from(files).filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)).slice(0, 100);
|
||||||
|
for (const f of arr) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setPics((p) => p.map((x) => x.id === id ? { ...x, src: reader.result as string } : x));
|
||||||
|
reader.readAsDataURL(f);
|
||||||
|
setPics((p) => [...p, { id, src: '', name: f.name, uploading: true }]);
|
||||||
|
const fd = new FormData(); fd.append('files', f);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/uploads', { method: 'POST', body: fd });
|
||||||
|
const j = await res.json();
|
||||||
|
const info = j.files?.[0];
|
||||||
|
setPics((p) => p.map((x) => x.id === id
|
||||||
|
? { ...x, uploading: false, source_path: info?.source_path, error: info?.error } : x));
|
||||||
|
} catch {
|
||||||
|
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Upload failed') } : x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPaste = (e: ClipboardEvent) => {
|
||||||
|
if (mode === 'generate') return;
|
||||||
|
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||||
|
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||||
|
};
|
||||||
|
window.addEventListener('paste', onPaste);
|
||||||
|
return () => window.removeEventListener('paste', onPaste);
|
||||||
|
}, [upload, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/models').then((r) => r.json()).then((j) => {
|
||||||
|
const ms = j.models || []; setModels(ms);
|
||||||
|
const def = ms.find((m: any) => m.is_default) || ms[0];
|
||||||
|
if (def) setModelKey(def.model_id);
|
||||||
|
}).catch(() => {});
|
||||||
|
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||||
|
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
|
||||||
|
loadPresets();
|
||||||
|
}, []);
|
||||||
|
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
||||||
|
const saveAsPreset = async () => {
|
||||||
|
const name = prompt(t('Name of the preset?')); if (!name?.trim()) return;
|
||||||
|
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||||
|
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
|
||||||
|
const body = {
|
||||||
|
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep',
|
||||||
|
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
|
||||||
|
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||||
|
output_ext: outputExt || null,
|
||||||
|
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
|
||||||
|
delivery, delivery_folder: gallery.trim() || null, delivery_target_id: targetId || null,
|
||||||
|
};
|
||||||
|
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
notify(t('Preset saved.')); loadPresets();
|
||||||
|
};
|
||||||
|
const deletePreset = async (id: string) => {
|
||||||
|
if (!id || !confirm(t('Delete preset?'))) return;
|
||||||
|
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify(t('Preset deleted.')); loadPresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Take a result over from the library: ?reuse=<itemId> loads it as a source.
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const reuseId = params.get('reuse');
|
||||||
|
if (!reuseId) return;
|
||||||
|
(async () => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setPics((p) => [...p, { id, src: `/api/items/${reuseId}/file?thumb=1`, name: 'continue-editing.png', uploading: true }]);
|
||||||
|
try {
|
||||||
|
const j = await fetch(`/api/items/${reuseId}/reuse`, { method: 'POST' }).then((r) => r.json());
|
||||||
|
setPics((p) => p.map((x) => x.id === id
|
||||||
|
? { ...x, uploading: false, source_path: j.source_path, name: j.filename || x.name, error: j.error } : x));
|
||||||
|
} catch {
|
||||||
|
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Could not load image') } : x));
|
||||||
|
}
|
||||||
|
history.replaceState(null, '', '/');
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleTask(id: string) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
let next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
|
||||||
|
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRecipe(r: any) {
|
||||||
|
if (!r) return;
|
||||||
|
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
|
||||||
|
setTasks(r.tasks || ['clean', 'format']);
|
||||||
|
const of = r.output_format || 'keep';
|
||||||
|
// Get the custom size back out of the recipe (no known key = custom size).
|
||||||
|
if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) {
|
||||||
|
setFormat('custom');
|
||||||
|
setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of);
|
||||||
|
} else setFormat(of);
|
||||||
|
setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : '');
|
||||||
|
setPortrait((r.orientation || 'portrait') !== 'landscape');
|
||||||
|
setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop');
|
||||||
|
setContourMm(Number(r.contour_mm) || 3);
|
||||||
|
setCustom(r.custom_instruction || '');
|
||||||
|
setDelivery(r.delivery || 'library');
|
||||||
|
setGallery(r.delivery_folder || '');
|
||||||
|
setTargetId(r.delivery_target_id || '');
|
||||||
|
if (r.model_key) setModelKey(r.model_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||||
|
// "Custom size" needs a valid entry, otherwise the run cannot start.
|
||||||
|
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
|
||||||
|
const canRun = !busy && !freeFmtInvalid && (
|
||||||
|
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
||||||
|
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
||||||
|
: desc.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!canRun) return;
|
||||||
|
setBusy(true);
|
||||||
|
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||||
|
const tasksOut = mode === 'each'
|
||||||
|
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||||
|
: (wantsFormat ? ['format'] : []);
|
||||||
|
const recipe = {
|
||||||
|
tasks: tasksOut,
|
||||||
|
output_format: wantsFormat ? effFormat : 'keep',
|
||||||
|
orientation: portrait ? 'portrait' : 'landscape',
|
||||||
|
crop_mode: crop, dpi: 300,
|
||||||
|
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||||
|
output_ext: outputExt || null,
|
||||||
|
custom_instruction: mode === 'each' ? (custom || null) : null,
|
||||||
|
delivery, model_key: modelKey || null,
|
||||||
|
delivery_folder: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
|
||||||
|
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
|
||||||
|
};
|
||||||
|
const body: any = { recipe, mode, delivery, private: priv };
|
||||||
|
if (mode !== 'each') body.prompt_text = desc.trim();
|
||||||
|
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/jobs', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.jobId) location.href = `/queue?job=${j.jobId}`;
|
||||||
|
else { notify(j.error || t('Could not create the job.')); setBusy(false); }
|
||||||
|
} catch { notify(t('Network error.')); setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadHint = mode === 'compose'
|
||||||
|
? t('Convert one image or combine several (e.g. subject + a photo of the dog).')
|
||||||
|
: t('Several at once, up to 100. PNG, JPG, WEBP, HEIC.');
|
||||||
|
|
||||||
|
// The key combination keeps its own styling, so the sentence carries a {keys}
|
||||||
|
// placeholder and is split on it instead of being glued together from fragments.
|
||||||
|
const dropText = t('Drag images here, paste with {keys} or tap to choose.').split('{keys}');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio">
|
||||||
|
{/* Mode switch */}
|
||||||
|
<div className="mode-bar">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button key={m.id} className={`mode-tab ${mode === m.id ? 'on' : ''}`} onClick={() => setMode(m.id)}>
|
||||||
|
<b>{t(m.name)}</b><span>{t(m.hint)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid">
|
||||||
|
{mode !== 'generate' && (
|
||||||
|
<section className="card">
|
||||||
|
<div className="cardHead">
|
||||||
|
<span className="mono-label">{mode === 'compose' ? t('Building blocks') : t('Sources')}{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||||
|
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>{t('remove all')}</button>}
|
||||||
|
</div>
|
||||||
|
<div className="stage">
|
||||||
|
{pics.length === 0 ? (
|
||||||
|
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
||||||
|
<span className="regmark" />
|
||||||
|
<p>{dropText[0]}<span className="kbd">⌘/Ctrl + V</span>{dropText[1]}</p>
|
||||||
|
<p className="hint">{uploadHint}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mini-grid">
|
||||||
|
{pics.map((b) => (
|
||||||
|
<div key={b.id} className={`mini-image ${b.error ? 'err' : ''}`}>
|
||||||
|
{b.src && <img src={b.src} alt="" />}
|
||||||
|
{b.uploading && <span className="loading" />}
|
||||||
|
{b.error && <span className="badge">!</span>}
|
||||||
|
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||||
|
onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="controls">
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('Presets')}</label>
|
||||||
|
<div className="preset-row">
|
||||||
|
<select className="select" value={presetSel}
|
||||||
|
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
|
||||||
|
<option value="">{t('Choose a preset …')}</option>
|
||||||
|
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{presetSel && <button className="pbtn" title={t('Delete preset')} onClick={() => { deletePreset(presetSel); setPresetSel(''); }}>✕</button>}
|
||||||
|
<button className="pbtn" title={t('Save the current settings as a preset')} onClick={saveAsPreset}>+ {t('save')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(mode === 'compose' || mode === 'generate') && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{mode === 'compose' ? t('What should come out of it?') : t('Description of the new image')}</label>
|
||||||
|
<textarea className="area large" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||||
|
placeholder={mode === 'compose'
|
||||||
|
? t('e.g. “Make the photo look like an oil painting.” or “…with our dog at the edge of the pool.”')
|
||||||
|
: t('e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”')} />
|
||||||
|
<div className="hint">{mode === 'compose'
|
||||||
|
? t('One image = convert (e.g. change the style). Several images = the first is the lead scene, the others supply people and subjects.')
|
||||||
|
: t('The more precise the description, the better the result.')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'each' && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('What should happen?')}</label>
|
||||||
|
<div className="modes">
|
||||||
|
{TASKS.map((m) => {
|
||||||
|
const on = tasks.includes(m.id);
|
||||||
|
const disabled = m.id === 'contour' && !hasCutout;
|
||||||
|
return (
|
||||||
|
<button key={m.id} disabled={disabled}
|
||||||
|
className={`mode ${on ? 'on' : ''} ${disabled ? 'off' : ''}`}
|
||||||
|
onClick={() => toggleTask(m.id)}>
|
||||||
|
<b>{t(m.name)}{on ? ' ✓' : ''}</b><span>{t(m.hint)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFormat && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{mode === 'each' ? t('Output format') : t('Format (optional)')}</label>
|
||||||
|
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||||
|
{FORMATS.map((f) => <option key={f.id} value={f.id}>{t(f.label)}</option>)}
|
||||||
|
</select>
|
||||||
|
{isFreeFmt && (
|
||||||
|
<>
|
||||||
|
<input className="input" value={customFmt} onChange={(e) => setCustomFmt(e.target.value)}
|
||||||
|
placeholder={format === 'sticker' ? t('e.g. 5 (=5×5 cm) or 5x7') : t('e.g. 25x35, or 5 for 5×5 cm')} />
|
||||||
|
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{theframeConflict && <div className="hint warn">{t('Cut out + The Frame makes no sense — pick one.')}</div>}
|
||||||
|
{effCm && (
|
||||||
|
<div className="switch">
|
||||||
|
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
|
||||||
|
<button className={!portrait ? 'on' : ''} onClick={() => setPortrait(false)}>{t('Landscape')}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{targetPx && <div className="hint">{t('Gives exactly {w} × {h} pixels at 300 dpi — ready to print.', { w: targetPx[0], h: targetPx[1] })}</div>}
|
||||||
|
{targetPx && mode !== 'generate' && (
|
||||||
|
<div className="switch subtle">
|
||||||
|
<button className={crop === 'crop' ? 'on' : ''} onClick={() => setCrop('crop')}>{t('Crop')}</button>
|
||||||
|
<button className={crop === 'extend' ? 'on' : ''} onClick={() => setCrop('extend')}>{t('Extend')}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{targetPx && mode !== 'generate' && crop === 'extend' && (
|
||||||
|
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('File format')}</label>
|
||||||
|
<div className="switch">
|
||||||
|
<button className={outputExt === '' ? 'on' : ''} onClick={() => setOutputExt('')}>{t('Default')}</button>
|
||||||
|
<button className={outputExt === 'png' ? 'on' : ''} onClick={() => setOutputExt('png')}>PNG</button>
|
||||||
|
<button className={outputExt === 'jpg' ? 'on' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
{outputExt === 'jpg' ? t('JPG — small and easy on the delivery target. Cut-out subjects stay PNG all the same (transparency).')
|
||||||
|
: outputExt === 'png' ? t('PNG — lossless, supports transparency. Large files.')
|
||||||
|
: t('The default from the admin settings (set globally).')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'each' && tasks.includes('contour') && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('Sticker border (mm)')}</label>
|
||||||
|
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||||
|
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('Quality')}</label>
|
||||||
|
<div className="switch wrap">
|
||||||
|
{models.map((m) => (
|
||||||
|
<button key={m.model_id} className={modelKey === m.model_id ? 'on' : ''}
|
||||||
|
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hint">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'each' && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('Own instruction (optional)')}</label>
|
||||||
|
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||||
|
placeholder={t('e.g. “Make the background lighter, leave the rest alone.”')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('Where to?')}</label>
|
||||||
|
<div className="switch">
|
||||||
|
<button className={delivery === 'library' ? 'on' : ''} onClick={() => setDelivery('library')}>{t('Library')}</button>
|
||||||
|
<button className={delivery === 'remote' ? 'on' : ''} onClick={() => setDelivery('remote')}>{t('Delivery target')}</button>
|
||||||
|
<button className={delivery === 'both' ? 'on' : ''} onClick={() => setDelivery('both')}>{t('Both')}</button>
|
||||||
|
</div>
|
||||||
|
{delivery !== 'library' && (
|
||||||
|
<div className="target">
|
||||||
|
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||||
|
<option value="">{t('Default delivery target')}</option>
|
||||||
|
<option value="mirror">{t('Backup mirror')}</option>
|
||||||
|
{targets.map((tg) => <option key={tg.id} value={tg.id}>{tg.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||||
|
placeholder={t('Folder on the target (empty = default)')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(me.private_allowed || me.private_forced) && (
|
||||||
|
<label className="private">
|
||||||
|
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
|
||||||
|
<span><b>{t('Private session')} {me.private_forced ? t('(enforced)') : ''}</b>
|
||||||
|
<em>{t('Generate only, remember nothing: no entry in the library, no delivery or backup, no stored prompt. The result can be fetched for a short while, then it is deleted.')}</em></span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="button" disabled={!canRun} onClick={run}>
|
||||||
|
{busy ? <><span className="spin" />{t('Being created …')}</>
|
||||||
|
: mode === 'each' ? (ready.length === 0 ? t('Start')
|
||||||
|
: ready.length === 1 ? t('Start · {n} image', { n: ready.length })
|
||||||
|
: t('Start · {n} images', { n: ready.length }))
|
||||||
|
: t('Generate image')}
|
||||||
|
</button>
|
||||||
|
<div className="hint center">{t('It keeps running on the server — you can close the window.')}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
|
<StudioStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudioStyles() {
|
||||||
|
return <style>{`
|
||||||
|
.studio{display:flex;flex-direction:column;gap:16px;}
|
||||||
|
.mode-bar{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
|
||||||
|
@media(max-width:560px){.mode-bar{grid-template-columns:1fr;}}
|
||||||
|
.mode-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
|
||||||
|
.mode-tab b{display:block;font-size:14px;font-weight:600;}
|
||||||
|
.mode-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
|
||||||
|
.mode-tab.on{border-color:var(--accent);background:var(--accent-bg);}
|
||||||
|
.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||||
|
@media(max-width:820px){.grid{grid-template-columns:1fr;}}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||||
|
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||||
|
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
|
||||||
|
.stage{padding:22px;min-height:290px;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.drop{width:100%;min-height:250px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
|
||||||
|
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
|
||||||
|
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
|
||||||
|
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
|
||||||
|
.mini-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:8px;width:100%;}
|
||||||
|
.mini-image{position:relative;aspect-ratio:1;border:1px solid var(--line);border-radius:3px;overflow:hidden;
|
||||||
|
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
|
||||||
|
.mini-image.err{border-color:var(--err);}
|
||||||
|
.mini-image img{width:100%;height:100%;object-fit:contain;display:block;}
|
||||||
|
.mini-image>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
|
||||||
|
.mini-image .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
|
||||||
|
.loading{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||||
|
.mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||||
|
.controls{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||||
|
.field label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||||
|
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||||
|
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
|
||||||
|
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
|
||||||
|
.area.large{font-size:15px;line-height:1.55;}
|
||||||
|
.hint{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||||
|
.hint.center{text-align:center;}
|
||||||
|
.hint.warn{color:var(--err);}
|
||||||
|
.modes{display:flex;flex-direction:column;gap:6px;}
|
||||||
|
.mode{text-align:left;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;cursor:pointer;font-family:inherit;}
|
||||||
|
.mode b{display:block;font-size:14px;font-weight:600;}
|
||||||
|
.mode span{display:block;font-size:11.5px;color:var(--soft);margin-top:2px;}
|
||||||
|
.mode.on{border-color:var(--accent);background:var(--accent-bg);}
|
||||||
|
.mode.off{opacity:.5;cursor:not-allowed;}
|
||||||
|
.switch{display:flex;gap:6px;margin-top:8px;}
|
||||||
|
.switch button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||||
|
.switch button.on{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||||
|
.switch.subtle button{font-size:12.5px;}
|
||||||
|
.target{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
|
||||||
|
.preset-row{display:flex;gap:6px;align-items:center;}
|
||||||
|
.preset-row .select{flex:1;}
|
||||||
|
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||||
|
.private{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
|
||||||
|
.private input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||||
|
.private b{display:block;font-size:13.5px;}
|
||||||
|
.private em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
|
||||||
|
.switch.wrap{flex-wrap:wrap;}
|
||||||
|
.switch.wrap button{flex:1 1 auto;min-width:110px;}
|
||||||
|
.button{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||||
|
.button:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||||||
|
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||||||
|
@keyframes sp{to{transform:rotate(360deg);}}
|
||||||
|
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||||
|
`}</style>;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
declare namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: import('./lib/auth').SessionUser | null;
|
||||||
|
locale: import('./lib/i18n').Locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
import '@fontsource/space-grotesk/400.css';
|
||||||
|
import '@fontsource/space-grotesk/500.css';
|
||||||
|
import '@fontsource/space-grotesk/700.css';
|
||||||
|
import '@fontsource/space-mono/400.css';
|
||||||
|
import '@fontsource/space-mono/700.css';
|
||||||
|
import '../styles/tokens.css';
|
||||||
|
import { LOCALE_NAMES, type Locale } from '../lib/i18n';
|
||||||
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
import { has } from '../lib/modules';
|
||||||
|
interface Props { title?: string }
|
||||||
|
const { title = 'Klarbild' } = Astro.props;
|
||||||
|
const user = Astro.locals.user;
|
||||||
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
|
const t = tFor(locale);
|
||||||
|
const dict = dictFor(locale);
|
||||||
|
const path = Astro.url.pathname;
|
||||||
|
const active = (href: string) => href === '/' ? path === '/' : path.startsWith(href);
|
||||||
|
|
||||||
|
// `short` is the label in the bottom bar on a phone: eight entries on a 390 px
|
||||||
|
// screen are about 49 px wide each, which is not enough room for a long word.
|
||||||
|
// Modules that are switched off leave the navigation entirely — a print-only
|
||||||
|
// instance should not advertise a library it does not have.
|
||||||
|
const nav = [
|
||||||
|
...(has('ai') ? [{ href: '/', label: t('Studio'), short: t('Studio'), icon: 'studio' }] : []),
|
||||||
|
...(has('ai') ? [{ href: '/library', label: t('Library'), short: t('Images'), icon: 'library' }] : []),
|
||||||
|
{ href: '/print', label: t('Print'), short: t('Print'), icon: 'print' },
|
||||||
|
...(has('ai') ? [{ href: '/queue', label: t('Queue'), short: t('Running'), icon: 'queue' }] : []),
|
||||||
|
...(has('share') ? [{ href: '/shares', label: t('Shared links'), short: t('Shared'), icon: 'share' }] : []),
|
||||||
|
{ href: '/help', label: t('Help'), short: t('Help'), icon: 'help' },
|
||||||
|
{ href: '/account', label: t('My account'), short: t('Account'), icon: 'account' },
|
||||||
|
...(user?.role === 'admin' ? [{ href: '/admin', label: t('Admin'), short: t('Admin'), icon: 'admin' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Language switch: same page, other language. The middleware turns `?lang=` into
|
||||||
|
// a cookie, so one click is enough and the choice sticks.
|
||||||
|
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
|
||||||
|
const switchHref = (() => {
|
||||||
|
const u = new URL(Astro.url);
|
||||||
|
u.searchParams.set('lang', otherLocale);
|
||||||
|
return u.pathname + u.search;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const S = 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
studio: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="17" height="17" rx="4"/><path d="M12 8.5v7M8.5 12h7"/></svg>`,
|
||||||
|
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
|
||||||
|
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
|
||||||
|
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
|
||||||
|
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
|
||||||
|
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
|
||||||
|
account: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="8.5" r="3.6"/><path d="M4.8 20c.7-3.6 3.7-5.6 7.2-5.6s6.5 2 7.2 5.6"/></svg>`,
|
||||||
|
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang={locale}>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#EAEAE3" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{user && (
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="/" class="brand"><span class="regmark"></span>klarbild</a>
|
||||||
|
<nav class="topnav">
|
||||||
|
{nav.map((n) => (
|
||||||
|
<a href={n.href} class:list={['tnav', { on: active(n.href) }]}>{n.label}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div class="logout">
|
||||||
|
<a class="langswitch" href={switchHref} hreflang={otherLocale} title={LOCALE_NAMES[otherLocale]}>{otherLocale.toUpperCase()}</a>
|
||||||
|
<span class="uname">{user.name}</span>
|
||||||
|
<form method="post" action="/api/auth/logout">
|
||||||
|
<button type="submit" aria-label={t('Sign out')}>{t('Sign out')}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signed out there is neither a top bar nor a tab bar, so no frame needs to
|
||||||
|
reserve room for them. The public pages (/s/…, /login, /tour, /password)
|
||||||
|
bring their own layout; a second <main> around them would also be
|
||||||
|
ambiguous for screen readers. */}
|
||||||
|
{user ? <main class="wrap"><slot /></main> : <slot />}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<nav class="tabbar" aria-label={t("Main navigation")}>
|
||||||
|
{nav.map((n) => (
|
||||||
|
<a href={n.href} class:list={['tab', { on: active(n.href) }]}>
|
||||||
|
<span class="ticon" set:html={ICONS[n.icon]} />
|
||||||
|
<span class="tlabel">{n.short}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 20px 16px calc(88px + env(safe-area-inset-bottom)); }
|
||||||
|
@media (min-width: 768px) { .wrap { padding: 26px 22px 80px; } }
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid var(--line);
|
||||||
|
background: color-mix(in oklab, var(--card) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.2) blur(8px);
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
padding-top: calc(10px + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--font-display); font-weight: 700; font-size: 1.2rem;
|
||||||
|
color: var(--ink); text-decoration: none; letter-spacing: -0.02em; }
|
||||||
|
.brand .regmark { transform: translateY(1px); }
|
||||||
|
.topnav { display: none; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* May shrink and, if it must, scroll: with seven entries the bar no
|
||||||
|
longer fits at 820 px and would push "Sign out" off screen. */
|
||||||
|
.topnav { display: flex; gap: 2px; margin-left: 8px; min-width: 0;
|
||||||
|
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||||
|
.topnav::-webkit-scrollbar { display: none; }
|
||||||
|
.tnav { padding: 7px 10px; border-radius: 999px; color: var(--ink-soft); white-space: nowrap;
|
||||||
|
text-decoration: none; font-size: 0.88rem; transition: background .15s, color .15s; }
|
||||||
|
.tnav:hover { color: var(--ink); background: var(--surface-2, var(--paper)); }
|
||||||
|
.tnav.on { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1040px) {
|
||||||
|
.topnav { gap: 4px; }
|
||||||
|
.tnav { padding: 7px 12px; font-size: 0.92rem; }
|
||||||
|
}
|
||||||
|
/* Never shrinks — the sign-out button has to stay reachable. */
|
||||||
|
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
|
||||||
|
.langswitch { font-family: var(--font-mono, monospace); font-size: 0.72rem; letter-spacing: .06em;
|
||||||
|
color: var(--ink-mute, var(--soft)); text-decoration: none; border: 1px solid var(--line);
|
||||||
|
border-radius: 999px; padding: 5px 9px; line-height: 1; }
|
||||||
|
.langswitch:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; text-decoration: none; }
|
||||||
|
.uname:hover { color: var(--accent); }
|
||||||
|
@media (min-width: 480px) { .uname { display: inline; } }
|
||||||
|
.logout button { background: none; border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
padding: 7px 14px; color: var(--ink-soft); cursor: pointer; font-family: inherit; font-size: 0.85rem;
|
||||||
|
transition: transform .06s ease, background .15s; min-height: 36px; }
|
||||||
|
.logout button:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
/* Bottom tab bar (mobile-first) */
|
||||||
|
.tabbar {
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||||
|
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||||
|
background: color-mix(in oklab, var(--card) 92%, transparent);
|
||||||
|
backdrop-filter: saturate(1.3) blur(12px);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.tab { display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||||
|
padding: 9px 4px 8px; text-decoration: none; color: var(--ink-mute, var(--soft));
|
||||||
|
font-size: 10.5px; letter-spacing: .01em; transition: color .15s, transform .06s; }
|
||||||
|
.tab:active { transform: scale(0.94); }
|
||||||
|
.tab.on { color: var(--accent); }
|
||||||
|
.tab .ticon { width: 22px; height: 22px; display: grid; place-items: center; }
|
||||||
|
.tab .ticon svg { width: 22px; height: 22px; }
|
||||||
|
.tlabel { line-height: 1; white-space: nowrap; }
|
||||||
|
/* Eight entries get tight — label and icon shrink a little, but the tap
|
||||||
|
target stays above 44 px tall. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.tab { padding: 8px 2px 7px; font-size: 9.5px; }
|
||||||
|
.tab .ticon, .tab .ticon svg { width: 20px; height: 20px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) { .tabbar { display: none; } }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import argon2 from 'argon2';
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { one, query } from './db';
|
||||||
|
|
||||||
|
export interface SessionUser { uid: string; role: 'user' | 'admin'; name: string; username?: string; }
|
||||||
|
/** What is actually in the cookie — including the fields that make it revocable. */
|
||||||
|
interface SessionPayload extends SessionUser { iat: number; ep: number }
|
||||||
|
|
||||||
|
const COOKIE = 'kb_session';
|
||||||
|
const MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
|
||||||
|
export const hashPassword = (pw: string) => argon2.hash(pw, { type: argon2.argon2id });
|
||||||
|
export const verifyPassword = (hash: string, pw: string) => argon2.verify(hash, pw).catch(() => false);
|
||||||
|
|
||||||
|
function secret(): string {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s) throw new Error('SESSION_SECRET is missing');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(data: string): string {
|
||||||
|
return createHmac('sha256', secret()).update(data).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSessionCookie(u: SessionUser, epoch = 0): string {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000), ep: epoch }))
|
||||||
|
.toString('base64url');
|
||||||
|
const value = `${payload}.${sign(payload)}`;
|
||||||
|
return `${COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearSessionCookie = () =>
|
||||||
|
`${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the cookie — signature **and** age.
|
||||||
|
*
|
||||||
|
* The age check is not cosmetics: `Max-Age` is a request to the browser, not a
|
||||||
|
* rule for the server. Without it a cookie that has leaked once stays valid
|
||||||
|
* without limit, years later and via curl as well.
|
||||||
|
*
|
||||||
|
* Whether the cookie is still valid **in substance** (password changed, role
|
||||||
|
* withdrawn, account deleted) cannot be decided here — that needs the database.
|
||||||
|
* `verifySession` in the middleware takes care of it.
|
||||||
|
*/
|
||||||
|
export function readSession(cookieHeader?: string | null): (SessionUser & { ep: number }) | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const raw = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
||||||
|
if (!raw) return null;
|
||||||
|
const [payload, sig] = raw.split('.');
|
||||||
|
if (!payload || !sig) return null;
|
||||||
|
const expected = sign(payload);
|
||||||
|
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionPayload;
|
||||||
|
if (!obj.uid || typeof obj.iat !== 'number') return null;
|
||||||
|
if (Math.floor(Date.now() / 1000) - obj.iat > MAX_AGE) return null; // expired as far as the server is concerned
|
||||||
|
return { uid: obj.uid, role: obj.role, name: obj.name, ep: Number(obj.ep) || 0 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-check in the database: does the account still exist, is the session
|
||||||
|
* epoch still valid, and which role does it have **now**?
|
||||||
|
*
|
||||||
|
* The role deliberately comes from the database and not from the cookie —
|
||||||
|
* otherwise a downgraded admin would keep their rights until the cookie expires.
|
||||||
|
* It is a point lookup over the primary key, which `tokenUser` does anyway.
|
||||||
|
*/
|
||||||
|
export async function verifySession(s: (SessionUser & { ep: number }) | null): Promise<SessionUser | null> {
|
||||||
|
if (!s?.uid) return null;
|
||||||
|
const u = await one<{ role: 'user' | 'admin'; display_name: string | null; username: string; session_epoch: number }>(
|
||||||
|
'SELECT role, display_name, username, session_epoch FROM users WHERE id = $1', [s.uid]);
|
||||||
|
if (!u) return null; // account deleted
|
||||||
|
if ((u.session_epoch ?? 0) !== s.ep) return null; // password changed or role changed
|
||||||
|
return { uid: s.uid, role: u.role, name: u.display_name || u.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate all sessions of an account — after a password change or a role change. */
|
||||||
|
export async function bumpSessionEpoch(userId: string): Promise<void> {
|
||||||
|
await query('UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1', [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string, password: string,
|
||||||
|
): Promise<{ user: SessionUser; epoch: number } | null> {
|
||||||
|
const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string; username: string; session_epoch: number }>(
|
||||||
|
'SELECT id, password_hash, role, display_name, username, session_epoch FROM users WHERE username=$1', [username]);
|
||||||
|
if (!u) return null;
|
||||||
|
if (!(await verifyPassword(u.password_hash, password))) return null;
|
||||||
|
return {
|
||||||
|
user: { uid: u.id, role: u.role, name: u.display_name || u.username },
|
||||||
|
epoch: u.session_epoch ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- simple in-memory rate limiter: max 5 failed attempts / 15 min per IP ---
|
||||||
|
const attempts = new Map<string, { count: number; until: number }>();
|
||||||
|
export function rateLimited(ip: string): boolean {
|
||||||
|
const e = attempts.get(ip);
|
||||||
|
return !!e && e.count >= 5 && Date.now() < e.until;
|
||||||
|
}
|
||||||
|
export function noteFailure(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const e = attempts.get(ip);
|
||||||
|
if (!e || now >= e.until) attempts.set(ip, { count: 1, until: now + 15 * 60_000 });
|
||||||
|
else e.count++;
|
||||||
|
}
|
||||||
|
export function clearFailures(ip: string): void { attempts.delete(ip); }
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Generic backup: mirrors finished results onto every backup target (the backup
|
||||||
|
// mirror from the settings + every delivery_target with is_backup), optionally
|
||||||
|
// with an accompanying metadata .md file. Structure per target:
|
||||||
|
// <base folder>/YYYY-MM/.
|
||||||
|
import { one, query } from './db';
|
||||||
|
import { getObject } from './storage';
|
||||||
|
import { uploadBuffer, loadTargetConfig, type RemoteTargetCfg } from './remotetarget';
|
||||||
|
import { loadMirrorConfig } from './mirror';
|
||||||
|
import { buildMetadataMd, sidecarName } from './metadata';
|
||||||
|
|
||||||
|
export interface BackupDest { key: string; label: string; cfg: RemoteTargetCfg; sidecar: boolean; }
|
||||||
|
|
||||||
|
/** Collect all active backup targets (including the metadata sidecar flag per target). */
|
||||||
|
export async function backupDestinations(): Promise<BackupDest[]> {
|
||||||
|
const dests: BackupDest[] = [];
|
||||||
|
const mirror = await loadMirrorConfig();
|
||||||
|
if (mirror) {
|
||||||
|
const s = await one<any>('SELECT mirror_metadata_sidecar FROM settings WHERE id=1');
|
||||||
|
dests.push({ key: 'mirror', label: 'Backup mirror', cfg: mirror, sidecar: !!s?.mirror_metadata_sidecar });
|
||||||
|
}
|
||||||
|
const targets = await query<any>(`SELECT id, name, metadata_sidecar FROM delivery_targets WHERE is_backup=true`);
|
||||||
|
for (const t of targets) {
|
||||||
|
const cfg = await loadTargetConfig(t.id);
|
||||||
|
if (cfg) dests.push({ key: t.id, label: t.name, cfg, sidecar: !!t.metadata_sidecar });
|
||||||
|
}
|
||||||
|
return dests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror one item onto every backup target. */
|
||||||
|
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
|
||||||
|
const dests = await backupDestinations();
|
||||||
|
if (!dests.length) return { mirrored: 0, failed: 0 };
|
||||||
|
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||||
|
if (!it?.result_path) return { mirrored: 0, failed: 0 };
|
||||||
|
|
||||||
|
const buf = await getObject(it.result_path);
|
||||||
|
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
|
||||||
|
let md: Buffer | null = null;
|
||||||
|
const anySidecar = dests.some((d) => d.sidecar);
|
||||||
|
if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8');
|
||||||
|
|
||||||
|
let mirrored = 0, failed = 0;
|
||||||
|
for (const d of dests) {
|
||||||
|
try {
|
||||||
|
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||||
|
if (d.sidecar && md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
|
||||||
|
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
|
||||||
|
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
|
||||||
|
if (d.key === 'mirror') await query(`UPDATE items SET mirror_status='mirrored' WHERE id=$1`, [itemId]);
|
||||||
|
mirrored++;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[backup]', d.label, itemId, e?.message || e);
|
||||||
|
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
|
||||||
|
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
|
||||||
|
if (d.key === 'mirror') await query(`UPDATE items SET mirror_status='failed' WHERE id=$1`, [itemId]);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mirrored, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back up every finished image onto every backup target (a catch-up run). */
|
||||||
|
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
|
||||||
|
const dests = await backupDestinations();
|
||||||
|
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
||||||
|
let mirrored = 0, failed = 0;
|
||||||
|
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||||
|
return { items: rows.length, mirrored, failed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Crop mathematics — **with no dependencies at all**, like printlayout.ts.
|
||||||
|
//
|
||||||
|
// This file exists because the same calculation used to sit in the project twice:
|
||||||
|
// once in the browser (preview and crop window in PrintApp.tsx) and once on the
|
||||||
|
// server (printrender.ts, for the PDF). Both did the same arithmetic — but
|
||||||
|
// nothing held them against each other. Two copies of the same formula drift
|
||||||
|
// apart sooner or later, and then the preview shows something other than what
|
||||||
|
// gets printed. Now there is only one source, and a test holds the server entry
|
||||||
|
// point against the browser entry point.
|
||||||
|
//
|
||||||
|
// All crops are **relative** (0…1 with respect to the source image) and therefore
|
||||||
|
// independent of resolution: the same crop applies to the small preview image as
|
||||||
|
// to the original.
|
||||||
|
|
||||||
|
export interface CropRel { x: number; y: number; w: number; h: number }
|
||||||
|
export type FitMode = 'cover' | 'contain';
|
||||||
|
|
||||||
|
/** Largest crop in the target ratio that lies **inside** the image. */
|
||||||
|
export function coverSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
|
||||||
|
const imgA = natW / natH;
|
||||||
|
return imgA > aspect ? { w: aspect / imgA, h: 1 } : { w: 1, h: imgA / aspect };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest crop in the target ratio that contains the **whole** image.
|
||||||
|
* It deliberately reaches beyond the edge of the image (w or h > 1) — that is
|
||||||
|
* where the letterbox area appears.
|
||||||
|
*/
|
||||||
|
export function containSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
|
||||||
|
const imgA = natW / natH;
|
||||||
|
return imgA > aspect ? { w: 1, h: imgA / aspect } : { w: aspect / imgA, h: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put it around the same centre point as an existing crop (otherwise centred). */
|
||||||
|
export function centred(w: number, h: number, around?: CropRel | null): CropRel {
|
||||||
|
const cx = around ? around.x + around.w / 2 : 0.5;
|
||||||
|
const cy = around ? around.y + around.h / 2 : 0.5;
|
||||||
|
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limits for zooming in and out. */
|
||||||
|
export interface CropLimits { min?: number; max?: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the crop within sensible limits.
|
||||||
|
* With `cover` it stays inside the image. With `contain` it may reach beyond —
|
||||||
|
* but not so far that nothing of the subject would be visible any more (at least
|
||||||
|
* 5 % has to stay inside the frame).
|
||||||
|
*
|
||||||
|
* `min`/`max` limit how far the user may zoom in and out. The **initial** crop
|
||||||
|
* has to be exempt from that: a very tall image in a very wide size arithmetically
|
||||||
|
* needs more than eight times — and a very wide size from a very tall image less
|
||||||
|
* than 2 %. If the limit bit there, the aspect ratio would no longer be right, and
|
||||||
|
* the image would be cropped after all even though "letterbox" is selected.
|
||||||
|
* `baseCrop` therefore widens the limits to whatever the size actually needs.
|
||||||
|
*/
|
||||||
|
export function clampCrop(c: CropRel, fit: FitMode = 'cover', limits: CropLimits = {}): CropRel {
|
||||||
|
const lo = limits.min ?? 0.02;
|
||||||
|
const hi = limits.max ?? 8;
|
||||||
|
if (fit === 'contain') {
|
||||||
|
const w = Math.min(hi, Math.max(lo, c.w));
|
||||||
|
const h = Math.min(hi, Math.max(lo, c.h));
|
||||||
|
return {
|
||||||
|
w, h,
|
||||||
|
x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)),
|
||||||
|
y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const w = Math.min(Math.min(1, hi), Math.max(lo, c.w));
|
||||||
|
const h = Math.min(Math.min(1, hi), Math.max(lo, c.h));
|
||||||
|
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limits that are guaranteed to let a given initial crop through. */
|
||||||
|
export const limitsFor = (c: { w: number; h: number }): CropLimits =>
|
||||||
|
({ min: Math.min(0.02, c.w, c.h), max: Math.max(8, c.w, c.h) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The crop an image starts out with in a target size.
|
||||||
|
* `around` preserves the centre point of the image if the user had already
|
||||||
|
* panned and then switches the size.
|
||||||
|
*/
|
||||||
|
export function baseCrop(
|
||||||
|
natW: number, natH: number, aspect: number, fit: FitMode = 'cover', around?: CropRel | null,
|
||||||
|
): CropRel {
|
||||||
|
const { w, h } = fit === 'contain' ? containSize(natW, natH, aspect) : coverSize(natW, natH, aspect);
|
||||||
|
// The zoom limits must never deform the initial crop — otherwise the aspect
|
||||||
|
// ratio would be gone and it would be cropped after all (see clampCrop).
|
||||||
|
return clampCrop(centred(w, h, around), fit, limitsFor({ w, h }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
// AES-256-GCM. ENCRYPTION_KEY = 64 hex characters (32 bytes).
|
||||||
|
function key(): Buffer {
|
||||||
|
const hex = process.env.ENCRYPTION_KEY || '';
|
||||||
|
if (hex.length !== 64) {
|
||||||
|
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes) long');
|
||||||
|
}
|
||||||
|
return Buffer.from(hex, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypts plain text → "iv.tag.ciphertext" (base64url parts). */
|
||||||
|
export function encrypt(plain: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [iv, tag, enc].map((b) => b.toString('base64url')).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(payload: string): string {
|
||||||
|
const [ivB, tagB, dataB] = payload.split('.');
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB, 'base64url'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(dataB, 'base64url')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masks a key for display: sk-or-…4f2a */
|
||||||
|
export function maskSecret(secret: string): string {
|
||||||
|
if (!secret) return '';
|
||||||
|
const tail = secret.slice(-4);
|
||||||
|
const head = secret.slice(0, Math.min(5, secret.length));
|
||||||
|
return `${head}…${tail}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||||
|
const res = await pool.query(text, params);
|
||||||
|
return res.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function one<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies migrations/*.sql in order, tracked in schema_migrations. */
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);
|
||||||
|
// The migrations live in the project root folder (container: /app/migrations).
|
||||||
|
// Do not look for them relative to the compiled module (dist/server/chunks).
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const candidates = [
|
||||||
|
join(process.cwd(), 'migrations'),
|
||||||
|
join(here, '..', '..', 'migrations'),
|
||||||
|
join(here, '..', '..', '..', 'migrations'),
|
||||||
|
];
|
||||||
|
const dir = candidates.find((d) => existsSync(d));
|
||||||
|
if (!dir) throw new Error(`migrations directory not found (checked: ${candidates.join(', ')})`);
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||||
|
for (const file of files) {
|
||||||
|
const done = await one('SELECT name FROM schema_migrations WHERE name=$1', [file]);
|
||||||
|
if (done) continue;
|
||||||
|
const sql = readFileSync(join(dir, file), 'utf8');
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [file]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(`[migrate] applied ${file}`);
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { one, query } from './db';
|
||||||
|
import { getObject } from './storage';
|
||||||
|
import { loadConfig, loadTargetConfig, uploadBuffer, type RemoteTargetCfg } from './remotetarget';
|
||||||
|
|
||||||
|
// Fallback folder on the remote target when nothing else is configured.
|
||||||
|
const DEFAULT_FOLDER = process.env.DELIVERY_DEFAULT_FOLDER || 'klarbild';
|
||||||
|
|
||||||
|
async function folderFor(item: { folder_id: string | null; job_id: string }): Promise<string> {
|
||||||
|
// 1) folder mapping 2) preset recipe 3) configured default folder 4) fallback constant
|
||||||
|
if (item.folder_id) {
|
||||||
|
const f = await one<{ delivery_folder: string | null }>('SELECT delivery_folder FROM folders WHERE id=$1', [item.folder_id]);
|
||||||
|
if (f?.delivery_folder) return f.delivery_folder;
|
||||||
|
}
|
||||||
|
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
|
if (j?.recipe_snapshot?.delivery_folder) return j.recipe_snapshot.delivery_folder;
|
||||||
|
const s = await one<{ delivery_default_folder: string | null }>('SELECT delivery_default_folder FROM settings WHERE id=1');
|
||||||
|
return s?.delivery_default_folder || DEFAULT_FOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cfgForKey(key: string | null | undefined): Promise<RemoteTargetCfg | null> {
|
||||||
|
if (!key) return null;
|
||||||
|
if (key === 'mirror') { const { loadMirrorConfig } = await import('./mirror'); return (await loadMirrorConfig()) as unknown as RemoteTargetCfg; }
|
||||||
|
if (key === 'remote') return loadConfig();
|
||||||
|
return loadTargetConfig(key); // UUID of an additional target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Target credentials including the source key: 1) folder target 2) preset-recipe target 3) the default delivery target. */
|
||||||
|
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: RemoteTargetCfg; key: string } | null> {
|
||||||
|
if (item.folder_id) {
|
||||||
|
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
|
||||||
|
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return { cfg: c, key: f.delivery_target_id }; }
|
||||||
|
}
|
||||||
|
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
|
const tid = j?.recipe_snapshot?.delivery_target_id;
|
||||||
|
if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; }
|
||||||
|
const c = await loadConfig();
|
||||||
|
return c ? { cfg: c, key: 'remote' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the metadata sidecar file switched on for this source? */
|
||||||
|
async function sidecarForKey(key: string): Promise<boolean> {
|
||||||
|
if (key === 'remote') { const s = await one<any>('SELECT delivery_metadata_sidecar FROM settings WHERE id=1'); return !!s?.delivery_metadata_sidecar; }
|
||||||
|
if (key === 'mirror') { const s = await one<any>('SELECT mirror_metadata_sidecar FROM settings WHERE id=1'); return !!s?.mirror_metadata_sidecar; }
|
||||||
|
const t = await one<any>('SELECT metadata_sidecar FROM delivery_targets WHERE id=$1', [key]);
|
||||||
|
return !!t?.metadata_sidecar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delivers a finished item to the matching folder on the delivery target.
|
||||||
|
* What gets delivered is the stored result **in the chosen format** (PNG/JPG per
|
||||||
|
* the global or the preset-recipe setting) — no forced conversion any more. */
|
||||||
|
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
|
||||||
|
if (!it?.result_path) return { ok: false, message: 'No result available.' };
|
||||||
|
const target = await targetFor(it);
|
||||||
|
if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'No delivery target configured.' }; }
|
||||||
|
const { cfg, key } = target;
|
||||||
|
|
||||||
|
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
|
||||||
|
try {
|
||||||
|
const folder = await folderFor(it);
|
||||||
|
const buf = await getObject(it.result_path);
|
||||||
|
const name = it.filename || `${it.id}.png`;
|
||||||
|
await uploadBuffer(cfg, folder, name, buf);
|
||||||
|
// Optional: metadata as an accompanying .md file — switchable per source.
|
||||||
|
if (await sidecarForKey(key)) {
|
||||||
|
try {
|
||||||
|
const { buildMetadataMd, sidecarName } = await import('./metadata');
|
||||||
|
await uploadBuffer(cfg, folder, sidecarName(name), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
|
||||||
|
} catch (e) { console.error('[delivery] metadata sidecar file failed', e); }
|
||||||
|
}
|
||||||
|
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
|
||||||
|
return { ok: true, message: `Delivered to "${folder}".` };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[delivery] item', itemId, 'failed:', e?.message || e);
|
||||||
|
await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]);
|
||||||
|
return { ok: false, message: e?.message || 'Delivery failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Work through all deliveries of a job that are still open (pending). */
|
||||||
|
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
||||||
|
const items = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]);
|
||||||
|
for (const it of items) await deliverItem(it.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Target sizes & exact pixel calculation (see 01 §5, 06 §3).
|
||||||
|
// px = round(cm / 2.54 * dpi)
|
||||||
|
|
||||||
|
export type Orientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
|
||||||
|
|
||||||
|
// cm sizes: [width, height] in the portrait convention (short side × long side)
|
||||||
|
const CM: Record<string, [number, number]> = {
|
||||||
|
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
|
||||||
|
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
|
||||||
|
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
|
||||||
|
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
|
||||||
|
'20x20': [20, 20], '30x30': [30, 30],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure screen sizes (fixed pixels, no cm/dpi)
|
||||||
|
const SCREEN: Record<string, [number, number]> = {
|
||||||
|
theframe: [3840, 2160], // 16:9 landscape
|
||||||
|
portrait916: [2160, 3840], // 9:16 portrait
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
|
||||||
|
|
||||||
|
export interface ResolveInput {
|
||||||
|
format: string; // a key from above, 'sticker', 'custom' or 'keep'
|
||||||
|
orientation?: Orientation;
|
||||||
|
dpi?: number;
|
||||||
|
customCm?: [number, number]; // for 'sticker'/'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns exact target pixels + label. `keep` → null (keep the original). */
|
||||||
|
export function resolveDimensions(input: ResolveInput): Dimensions | null {
|
||||||
|
const { format } = input;
|
||||||
|
const dpi = input.dpi ?? 300;
|
||||||
|
const orient = input.orientation ?? 'portrait';
|
||||||
|
|
||||||
|
if (format === 'keep') return null;
|
||||||
|
|
||||||
|
if (format in SCREEN) {
|
||||||
|
const [w, h] = SCREEN[format];
|
||||||
|
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Portrait' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cm: [number, number] | undefined;
|
||||||
|
if (format in CM) cm = CM[format];
|
||||||
|
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
|
||||||
|
else {
|
||||||
|
// Read freely defined sizes straight out of the key — no database migration needed:
|
||||||
|
// "sticker5" / "sticker-7,5" → square N×N cm; "25x35" / "25×35" → W×H cm.
|
||||||
|
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||||
|
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||||
|
const num = (s: string) => parseFloat(s.replace(',', '.'));
|
||||||
|
if (sq) { const n = num(sq[1]); cm = [n, n]; }
|
||||||
|
else if (wh) cm = [num(wh[1]), num(wh[2])];
|
||||||
|
}
|
||||||
|
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
|
||||||
|
throw new Error(`Unknown format: ${format}`);
|
||||||
|
|
||||||
|
// Portrait convention → apply the requested orientation
|
||||||
|
let [wCm, hCm] = cm;
|
||||||
|
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
|
||||||
|
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
|
||||||
|
label: `${cm[0]}×${cm[1]} cm` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simplified aspect ratio as "w:h" for the image API. */
|
||||||
|
export function aspectRatio(w: number, h: number): string {
|
||||||
|
const g = gcd(w, h);
|
||||||
|
return `${Math.round(w / g)}:${Math.round(h / g)}`;
|
||||||
|
}
|
||||||
|
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
|
||||||
|
|
||||||
|
/** Is the native resolution enough for real 300 dpi? (A hint in the UI.) */
|
||||||
|
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
|
||||||
|
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import de from '../locales/de.json';
|
||||||
|
import { makeT, type Dict, type Locale, type TFunction } from './i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side dictionaries. Only this module imports the JSON, so the bundler
|
||||||
|
* keeps it out of the browser bundle; the layout passes the right one down.
|
||||||
|
*/
|
||||||
|
const DICTS: Partial<Record<Locale, Dict>> = { de: de as Dict };
|
||||||
|
|
||||||
|
/** The dictionary for a locale, or `null` for English, which needs none. */
|
||||||
|
export function dictFor(locale: Locale): Dict | null {
|
||||||
|
return DICTS[locale] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for Astro frontmatter: `const t = tFor(locale)`. */
|
||||||
|
export function tFor(locale: Locale): TFunction {
|
||||||
|
return makeT(dictFor(locale));
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Translation.
|
||||||
|
*
|
||||||
|
* The English sentence *is* the key. `t('Sheet layout')` returns "Sheet layout"
|
||||||
|
* in English and looks up "Bogen-Layout" in `locales/de.json` when the instance
|
||||||
|
* runs in German. That has two consequences worth knowing:
|
||||||
|
*
|
||||||
|
* - There is no key file to keep in sync and no `print.sheet.title` indirection
|
||||||
|
* to decode. You read the source and you see the sentence the user sees.
|
||||||
|
* - A missing translation degrades to English rather than to a raw key, so a
|
||||||
|
* half-finished locale is still a usable instance.
|
||||||
|
*
|
||||||
|
* Placeholders are named and written in braces: `t('{done} of {total} done',
|
||||||
|
* { done, total })`. Never build a sentence by concatenation — word order
|
||||||
|
* differs between languages, and a concatenated sentence cannot be translated.
|
||||||
|
*
|
||||||
|
* This module deliberately imports no dictionary. English needs none, and a
|
||||||
|
* static import would ship every other language to every visitor. The server
|
||||||
|
* loads the dictionary from `i18n.server.ts`; the browser picks it up from the
|
||||||
|
* JSON block the layout renders (see `useT.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LOCALES = ['en', 'de'] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
export const LOCALE_NAMES: Record<Locale, string> = {
|
||||||
|
en: 'English',
|
||||||
|
de: 'Deutsch',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Dict = Record<string, string>;
|
||||||
|
export type TFunction = (text: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** A translate function bound to one dictionary. Cheap — make one per render. */
|
||||||
|
export function makeT(dict?: Dict | null): TFunction {
|
||||||
|
return (text, vars) => {
|
||||||
|
let out = dict?.[text] ?? text;
|
||||||
|
if (vars) {
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
out = out.split('{' + key + '}').join(String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocale(value: unknown): value is Locale {
|
||||||
|
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which language this request gets.
|
||||||
|
*
|
||||||
|
* Order: an explicit `?lang=` in the URL (which also sets the cookie), then the
|
||||||
|
* cookie, then the instance default from `KLARBILD_LOCALE`, then English. The
|
||||||
|
* browser's `Accept-Language` deliberately does not decide: a self-hosted tool
|
||||||
|
* belongs to its operator, and an instance set up in German should not flip to
|
||||||
|
* English because a visitor's laptop is configured that way.
|
||||||
|
*/
|
||||||
|
export function resolveLocale(opts: { query?: string | null; cookie?: string | null; env?: string | null }): Locale {
|
||||||
|
if (isLocale(opts.query)) return opts.query;
|
||||||
|
if (isLocale(opts.cookie)) return opts.cookie;
|
||||||
|
if (isLocale(opts.env)) return opts.env;
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCALE_COOKIE = 'klarbild_lang';
|
||||||
|
|
||||||
|
/** Where the layout parks the dictionary for the browser to find. */
|
||||||
|
export const DICT_ELEMENT_ID = 'klarbild-i18n';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { runMigrations } from './db';
|
||||||
|
import { ensureBucket } from './storage';
|
||||||
|
import { seed } from './seed';
|
||||||
|
import { has, activeModules } from './modules';
|
||||||
|
|
||||||
|
let started: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** One-time server initialisation (idempotent, triggered by the first request). */
|
||||||
|
export function ensureInit(): Promise<void> {
|
||||||
|
if (!started) {
|
||||||
|
started = (async () => {
|
||||||
|
await runMigrations();
|
||||||
|
try { await ensureBucket(); } catch (e) { console.error('[init] object storage not ready:', e); }
|
||||||
|
await seed();
|
||||||
|
|
||||||
|
// The worker only exists to run image jobs through a model. A print-only
|
||||||
|
// instance has no queue to drain, so it does not start one.
|
||||||
|
if (has('ai')) {
|
||||||
|
try { const { startImageWorker } = await import('../worker'); await startImageWorker(); }
|
||||||
|
catch (e) { console.error('[init] worker failed to start:', e); }
|
||||||
|
}
|
||||||
|
if (has('telegram')) {
|
||||||
|
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
|
||||||
|
catch (e) { console.error('[init] Telegram setup failed:', e); }
|
||||||
|
}
|
||||||
|
try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); }
|
||||||
|
catch (e) { console.error('[init] maintenance failed to start:', e); }
|
||||||
|
|
||||||
|
console.log(`[init] Klarbild ready — modules: ${activeModules().join(', ')}`);
|
||||||
|
})().catch((e) => { started = null; throw e; });
|
||||||
|
}
|
||||||
|
return started;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Rate limiting, and the question of whose sender address you believe.
|
||||||
|
//
|
||||||
|
// Deliberately without a dependency and in memory: Klarbild runs as **one** Node
|
||||||
|
// process. If a second one were ever added, this belongs in the database or in
|
||||||
|
// Redis — and then this file is the one place you swap out.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's actual address.
|
||||||
|
*
|
||||||
|
* Important and easy to overlook: Astro's `clientAddress` takes the **first**
|
||||||
|
* value out of `X-Forwarded-For`. That value is sent by the attacker themselves
|
||||||
|
* — a reverse proxy appends the real address at the end. Any limit that builds
|
||||||
|
* on `clientAddress` can be circumvented by varying the header from request to
|
||||||
|
* request.
|
||||||
|
*
|
||||||
|
* That is why it counts from the right: with `TRUSTED_PROXIES=1` (the default,
|
||||||
|
* one proxy in front such as Traefik) the last entry is the one the proxy set
|
||||||
|
* itself — nobody from outside can forge that. Without a proxy
|
||||||
|
* (`TRUSTED_PROXIES=0`) the header is not considered at all.
|
||||||
|
*/
|
||||||
|
export function realIp(request: Request, fallback?: string | null): string {
|
||||||
|
const trusted = Number(process.env.TRUSTED_PROXIES ?? '1');
|
||||||
|
if (!Number.isFinite(trusted) || trusted <= 0) return fallback || 'unknown';
|
||||||
|
const chain = (request.headers.get('x-forwarded-for') || '')
|
||||||
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!chain.length) return fallback || 'unknown';
|
||||||
|
// From the right, as many hops back as there are trustworthy proxies.
|
||||||
|
const i = Math.max(0, chain.length - trusted);
|
||||||
|
return chain[i] || fallback || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry { n: number; until: number }
|
||||||
|
const buckets = new Map<string, Entry>();
|
||||||
|
let lastCleanup = 0;
|
||||||
|
|
||||||
|
/** Remove expired entries — selectively, not everything at once. */
|
||||||
|
function cleanUp(now: number): void {
|
||||||
|
if (now - lastCleanup < 60_000) return;
|
||||||
|
lastCleanup = now;
|
||||||
|
for (const [k, e] of buckets) if (e.until < now) buckets.delete(k);
|
||||||
|
// Emergency brake in case too much piles up anyway: the oldest ones first.
|
||||||
|
if (buckets.size > 50_000) {
|
||||||
|
const byAge = [...buckets.entries()].sort((a, b) => a[1].until - b[1].until);
|
||||||
|
for (const [k] of byAge.slice(0, buckets.size - 25_000)) buckets.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count one attempt. Returns `true` when the limit has been exceeded.
|
||||||
|
*
|
||||||
|
* Unlike before, the whole table is **not** cleared when it grows large — that
|
||||||
|
* would have let an attacker reset everybody else's counters along with theirs.
|
||||||
|
*/
|
||||||
|
export function tooOften(key: string, max: number, windowMs: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
cleanUp(now);
|
||||||
|
const e = buckets.get(key);
|
||||||
|
if (!e || e.until < now) { buckets.set(key, { n: 1, until: now + windowMs }); return false; }
|
||||||
|
e.n++;
|
||||||
|
return e.n > max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the counter — after a successful operation. */
|
||||||
|
export const resetPassword = (key: string): void => { buckets.delete(key); };
|
||||||
|
|
||||||
|
/** How many attempts are still free in the running window. */
|
||||||
|
export function attemptsLeft(key: string, max: number): number {
|
||||||
|
const e = buckets.get(key);
|
||||||
|
if (!e || e.until < Date.now()) return max;
|
||||||
|
return Math.max(0, max - e.n);
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
// Sending email through your own mailbox provider (SMTP).
|
||||||
|
//
|
||||||
|
// Deliberately through the existing outgoing mail server instead of a sending
|
||||||
|
// service: the domain sits there anyway, so SPF and DKIM are right by
|
||||||
|
// themselves, and no further provider comes into play that would get to see the
|
||||||
|
// mails.
|
||||||
|
//
|
||||||
|
// The credentials sit encrypted in `settings` (AES-256-GCM, like the OpenRouter
|
||||||
|
// key and the delivery target password) and **never** in the repository.
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { one } from './db.ts';
|
||||||
|
import { decrypt } from './crypto.ts';
|
||||||
|
|
||||||
|
export interface MailCfg {
|
||||||
|
host: string; port: number; secure: boolean;
|
||||||
|
user: string; password: string; from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailSource { cfg: MailCfg; source: 'database' | 'environment' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP credentials — from the settings first, otherwise from the environment.
|
||||||
|
*
|
||||||
|
* The same order of precedence as for the OpenRouter key: whatever someone
|
||||||
|
* enters in the admin area wins. The environment variables are the starting
|
||||||
|
* value, so that sending works right after a fresh setup without anyone having
|
||||||
|
* to type credentials in — and so that the password sits **only** there and in
|
||||||
|
* the encrypted column, never in the repository.
|
||||||
|
*/
|
||||||
|
export async function loadMailSource(): Promise<MailSource | null> {
|
||||||
|
const s = await one<any>(
|
||||||
|
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`);
|
||||||
|
|
||||||
|
if (s?.smtp_host && s?.smtp_user && s?.smtp_password) {
|
||||||
|
try {
|
||||||
|
return { source: 'database', cfg: {
|
||||||
|
host: s.smtp_host,
|
||||||
|
port: Number(s.smtp_port) || 465,
|
||||||
|
secure: s.smtp_secure !== false,
|
||||||
|
user: s.smtp_user,
|
||||||
|
password: decrypt(s.smtp_password),
|
||||||
|
from: s.smtp_from || s.smtp_user,
|
||||||
|
} };
|
||||||
|
} catch {
|
||||||
|
// Decryption failed (after a change of the ENCRYPTION_KEY, for example) —
|
||||||
|
// then the environment is better than nothing.
|
||||||
|
console.error('[mail] Stored password not readable, falling back to the environment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = process.env;
|
||||||
|
if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) {
|
||||||
|
return { source: 'environment', cfg: {
|
||||||
|
host: e.SMTP_HOST,
|
||||||
|
port: Number(e.SMTP_PORT) || 465,
|
||||||
|
secure: e.SMTP_SECURE !== 'false',
|
||||||
|
user: e.SMTP_USER,
|
||||||
|
password: e.SMTP_PASSWORD,
|
||||||
|
from: e.SMTP_FROM || e.SMTP_USER,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the credentials, without where they came from. */
|
||||||
|
export async function loadMailConfig(): Promise<MailCfg | null> {
|
||||||
|
return (await loadMailSource())?.cfg ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig());
|
||||||
|
|
||||||
|
export interface MailInput {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
/** Body text. Is sent as the plain-text version as well. */
|
||||||
|
text: string;
|
||||||
|
/** Optional HTML body; without one the text is simply framed. */
|
||||||
|
html?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const esc = (s: string) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain, readable HTML frame. No external images, no tracking pixels, no
|
||||||
|
* fonts from someone else's servers — that would be blocked in many mailboxes
|
||||||
|
* anyway and would be impolite towards the recipient.
|
||||||
|
*/
|
||||||
|
export function mailLayout(title: string, paragraphs: string[], button?: { text: string; url: string }): string {
|
||||||
|
const p = paragraphs.map((a) => `<p style="margin:0 0 14px;line-height:1.6">${a}</p>`).join('');
|
||||||
|
const btn = button
|
||||||
|
? `<p style="margin:22px 0"><a href="${esc(button.url)}" style="display:inline-block;background:#1B3BE0;color:#fff;
|
||||||
|
text-decoration:none;padding:12px 20px;border-radius:4px;font-weight:600">${esc(button.text)}</a></p>
|
||||||
|
<p style="margin:0 0 14px;font-size:13px;color:#6A685D">If the button does not work:<br>
|
||||||
|
<a href="${esc(button.url)}" style="color:#1B3BE0;word-break:break-all">${esc(button.url)}</a></p>`
|
||||||
|
: '';
|
||||||
|
return `<!doctype html><html lang="en"><body style="margin:0;background:#EAEAE3;padding:24px;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#16150F;font-size:15px">
|
||||||
|
<div style="max-width:560px;margin:0 auto;background:#FBFBF7;border:1px solid #D7D6CC;border-radius:4px;padding:28px">
|
||||||
|
<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#6A685D;margin-bottom:18px">Klarbild</div>
|
||||||
|
<h1 style="font-size:20px;margin:0 0 16px;line-height:1.3">${esc(title)}</h1>
|
||||||
|
${p}${btn}
|
||||||
|
</div>
|
||||||
|
<div style="max-width:560px;margin:14px auto 0;font-size:12px;color:#6A685D;text-align:center">
|
||||||
|
This message comes from your own Klarbild installation.
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a mail. Throws if no credentials are configured — the callers decide
|
||||||
|
* whether that is an error or only a notice.
|
||||||
|
*/
|
||||||
|
export async function sendMail(m: MailInput): Promise<void> {
|
||||||
|
const cfg = await loadMailConfig();
|
||||||
|
if (!cfg) throw new Error('No email credentials configured.');
|
||||||
|
const tx = nodemailer.createTransport({
|
||||||
|
host: cfg.host, port: cfg.port, secure: cfg.secure,
|
||||||
|
auth: { user: cfg.user, pass: cfg.password },
|
||||||
|
// No endless hanging when the server does not answer.
|
||||||
|
connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await tx.sendMail({
|
||||||
|
from: cfg.from, to: m.to, subject: m.subject,
|
||||||
|
text: m.text,
|
||||||
|
html: m.html || mailLayout(m.subject, m.text.split('\n\n').map(esc)),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
tx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `sendMail`, but reports errors only into the log. For notifications. */
|
||||||
|
export async function trySendMail(m: MailInput): Promise<boolean> {
|
||||||
|
try { await sendMail(m); return true; }
|
||||||
|
catch (e: any) { console.error('[mail] sending failed:', e?.message || e); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check the connection without sending anything — for the test button in the admin area. */
|
||||||
|
export async function testMail(): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const cfg = await loadMailConfig();
|
||||||
|
if (!cfg) return { ok: false, message: 'No email credentials configured.' };
|
||||||
|
const tx = nodemailer.createTransport({
|
||||||
|
host: cfg.host, port: cfg.port, secure: cfg.secure,
|
||||||
|
auth: { user: cfg.user, pass: cfg.password },
|
||||||
|
connectionTimeout: 15000, greetingTimeout: 10000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await tx.verify();
|
||||||
|
return { ok: true, message: `Connection to ${cfg.host}:${cfg.port} succeeded.` };
|
||||||
|
} catch (e: any) {
|
||||||
|
// Provider messages can contain the user name — keep it short.
|
||||||
|
const raw = String(e?.message || e);
|
||||||
|
const short = /auth/i.test(raw) ? 'Sign-in refused — check the user name or the password.'
|
||||||
|
: /timeout|ETIMEDOUT|ECONNREFUSED/i.test(raw) ? 'Server not reachable — check the host and the port.'
|
||||||
|
: /certificate|self.signed/i.test(raw) ? 'Certificate error — check the encryption (port 465 = SSL, 587 = STARTTLS).'
|
||||||
|
: 'Connection failed.';
|
||||||
|
return { ok: false, message: short };
|
||||||
|
} finally {
|
||||||
|
tx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rough plausibility check of an address.
|
||||||
|
*
|
||||||
|
* Control characters are checked against the **raw** value, not the trimmed
|
||||||
|
* one: in JavaScript `$` without the `m` flag also matches in front of a
|
||||||
|
* trailing line break, and `trim()` would take it away anyway. The two together
|
||||||
|
* would have let `"a@b.de\n"` through — and that is exactly how every header
|
||||||
|
* injection starts.
|
||||||
|
*/
|
||||||
|
export const isEmail = (v: string): boolean => {
|
||||||
|
const raw = String(v ?? '');
|
||||||
|
if (/[\r\n\t\0]/.test(raw)) return false;
|
||||||
|
const s = raw.trim();
|
||||||
|
return !!s && s.length <= 254 && /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s);
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Storage management: show usage, clean up (retention), delete sources.
|
||||||
|
// The aim: do not let the server silt up.
|
||||||
|
import { stat, readdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { one, query } from './db';
|
||||||
|
import { deleteObject } from './storage';
|
||||||
|
|
||||||
|
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||||
|
const DIR = process.env.STORAGE_DIR || '/data';
|
||||||
|
|
||||||
|
async function dirBytes(path: string): Promise<{ bytes: number; files: number }> {
|
||||||
|
let bytes = 0, files = 0;
|
||||||
|
let entries: any[] = [];
|
||||||
|
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; }
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = join(path, e.name);
|
||||||
|
if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; }
|
||||||
|
else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
return { bytes, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageStats {
|
||||||
|
driver: string; bytes: number | null; files: number | null;
|
||||||
|
sources: number; results: number; thumbs: number; items: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage figures. Byte accuracy only with the fs driver. */
|
||||||
|
export async function storageStats(): Promise<StorageStats> {
|
||||||
|
const counts = await one<any>(`SELECT
|
||||||
|
count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources,
|
||||||
|
count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results,
|
||||||
|
count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs,
|
||||||
|
count(*)::int AS items FROM items`);
|
||||||
|
let bytes: number | null = null, files: number | null = null;
|
||||||
|
if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; }
|
||||||
|
return { driver: DRIVER, bytes, files,
|
||||||
|
sources: counts?.sources || 0, results: counts?.results || 0,
|
||||||
|
thumbs: counts?.thumbs || 0, items: counts?.items || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes items (objects included) that are older than N days. */
|
||||||
|
export async function runRetention(days: number): Promise<{ deleted: number }> {
|
||||||
|
if (!days || days <= 0) return { deleted: 0 };
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT id, source_path, source_paths, result_path, thumb_path FROM items
|
||||||
|
WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]);
|
||||||
|
for (const it of rows) {
|
||||||
|
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||||
|
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||||
|
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||||
|
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||||
|
}
|
||||||
|
return { deleted: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Produces missing preview images afterwards (for a fast library view). */
|
||||||
|
export async function rebuildThumbs(): Promise<{ made: number }> {
|
||||||
|
const { getObject, putObject, thumbKey } = await import('./storage');
|
||||||
|
const sharp = (await import('sharp')).default;
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT id, result_path FROM items WHERE status='done' AND result_path IS NOT NULL AND thumb_path IS NULL`);
|
||||||
|
let made = 0;
|
||||||
|
for (const it of rows) {
|
||||||
|
try {
|
||||||
|
const out = await sharp(await getObject(it.result_path), { failOn: 'none' })
|
||||||
|
.resize(600, 600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 72 }).toBuffer();
|
||||||
|
const tk = thumbKey(it.id);
|
||||||
|
await putObject(tk, out, 'image/webp');
|
||||||
|
await query(`UPDATE items SET thumb_path=$2 WHERE id=$1`, [it.id, tk]);
|
||||||
|
made++;
|
||||||
|
} catch (e) { console.error('[maintenance] Thumb', it.id, e); }
|
||||||
|
}
|
||||||
|
return { made };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes only the source images of finished items (the results stay). */
|
||||||
|
export async function purgeSources(): Promise<{ purged: number }> {
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT id, source_path, source_paths FROM items WHERE status='done'
|
||||||
|
AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`);
|
||||||
|
let purged = 0;
|
||||||
|
for (const it of rows) {
|
||||||
|
const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||||
|
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||||
|
await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]);
|
||||||
|
purged += keys.length;
|
||||||
|
}
|
||||||
|
return { purged };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors all finished images to all backup targets (backup mirror + is_backup targets). */
|
||||||
|
export async function mirrorAllToBackup(): Promise<{ mirrored: number; failed: number; total: number }> {
|
||||||
|
const { backupAll } = await import('./backup');
|
||||||
|
const r = await backupAll();
|
||||||
|
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Forget" private jobs: delete results + jobs after a short time. */
|
||||||
|
export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number }> {
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT i.id, i.result_path, i.thumb_path FROM items i JOIN jobs j ON j.id=i.job_id
|
||||||
|
WHERE j.private=true AND i.created_at < now() - ($1 || ' minutes')::interval`, [String(olderThanMin)]);
|
||||||
|
for (const it of rows) {
|
||||||
|
for (const k of [it.result_path, it.thumb_path].filter(Boolean)) await deleteObject(k).catch(() => {});
|
||||||
|
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||||
|
}
|
||||||
|
// remove empty private jobs
|
||||||
|
await query(`DELETE FROM jobs WHERE private=true AND id NOT IN (SELECT DISTINCT job_id FROM items WHERE job_id IS NOT NULL)`);
|
||||||
|
return { deleted: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear away records and expired reset links.
|
||||||
|
*
|
||||||
|
* Both tables used to grow without bound. With `share_views` that is not only a
|
||||||
|
* question of space: they are records of when somebody looked at an image.
|
||||||
|
* Keeping them longer than you need them would simply be unnecessary — 90 days
|
||||||
|
* are enough for "was that actually looked at?".
|
||||||
|
*/
|
||||||
|
export async function purgeTracking(days = 90): Promise<{ views: number; resets: number }> {
|
||||||
|
const v = await query<{ id: string }>(
|
||||||
|
`DELETE FROM share_views WHERE at < now() - ($1 || ' days')::interval RETURNING id`, [String(days)]);
|
||||||
|
const r = await query<{ token_hash: string }>(
|
||||||
|
`DELETE FROM password_resets
|
||||||
|
WHERE expires_at < now() - interval '7 days' RETURNING token_hash`);
|
||||||
|
return { views: v.length, resets: r.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
/** Maintenance run: retention + forgetting private sessions. */
|
||||||
|
export function startMaintenance(): void {
|
||||||
|
if (timer) return;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||||
|
if (s?.retention_days && s.retention_days > 0) {
|
||||||
|
const r = await runRetention(s.retention_days);
|
||||||
|
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} old items removed.`);
|
||||||
|
}
|
||||||
|
const p = await purgePrivate(60);
|
||||||
|
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
||||||
|
const t = await purgeTracking(90);
|
||||||
|
if (t.views || t.resets) console.log(`[maintenance] Cleaned up: ${t.views} views, ${t.resets} old reset links.`);
|
||||||
|
} catch (e) { console.error('[maintenance]', e); }
|
||||||
|
};
|
||||||
|
timer = setInterval(tick, 30 * 60 * 1000); // every 30 min (also for the private purge)
|
||||||
|
setTimeout(tick, 60 * 1000); // first run after 1 min
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { one } from './db';
|
||||||
|
|
||||||
|
/** Builds an accompanying metadata file (Markdown) for an item. */
|
||||||
|
export async function buildMetadataMd(itemId: string): Promise<string> {
|
||||||
|
const it = await one<any>(
|
||||||
|
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
|
||||||
|
i.created_at, i.delivery_status, i.color_tag, j.mode, j.recipe_snapshot
|
||||||
|
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
|
||||||
|
if (!it) return '';
|
||||||
|
const r = it.recipe_snapshot || {};
|
||||||
|
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
|
||||||
|
const TAG: Record<string, string> = { red: 'Red', orange: 'Orange', green: 'Green', final: 'Final' };
|
||||||
|
const lines = [
|
||||||
|
`# ${it.filename || itemId}`,
|
||||||
|
'',
|
||||||
|
`- **Created:** ${new Date(it.created_at).toISOString()}`,
|
||||||
|
`- **Mode:** ${it.mode || 'each'}`,
|
||||||
|
tasks ? `- **Tasks:** ${tasks}` : '',
|
||||||
|
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
|
||||||
|
it.output_px ? `- **Resolution:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
|
||||||
|
`- **Transparency:** ${it.has_alpha ? 'yes' : 'no'}`,
|
||||||
|
it.model_used ? `- **Model:** ${it.model_used}` : '',
|
||||||
|
it.color_tag ? `- **Tag:** ${TAG[it.color_tag] || it.color_tag}` : '',
|
||||||
|
it.cost != null ? `- **Cost:** $${Number(it.cost).toFixed(4)}` : '',
|
||||||
|
'',
|
||||||
|
'## Prompt',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
(it.prompt_used || '(no prompt stored)'),
|
||||||
|
'```',
|
||||||
|
].filter((l) => l !== '');
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File name of the sidecar file: <result>.md */
|
||||||
|
export function sidecarName(filename: string | null): string {
|
||||||
|
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
|
||||||
|
return `${base}.md`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// A second copy on a backup mirror (SFTP or FTPS, same as the delivery target).
|
||||||
|
// Results are mirrored onto the backup mirror in addition to the library and
|
||||||
|
// the delivery target.
|
||||||
|
import posixpath from 'node:path/posix';
|
||||||
|
import { one, query } from './db';
|
||||||
|
import { decrypt } from './crypto';
|
||||||
|
import { getObject } from './storage';
|
||||||
|
|
||||||
|
export interface MirrorCfg {
|
||||||
|
host: string; protocol: 'ftps' | 'sftp'; port: number;
|
||||||
|
user: string; password: string; basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMirrorConfig(): Promise<MirrorCfg | null> {
|
||||||
|
const s = await one<any>(`SELECT mirror_enabled, mirror_host, mirror_protocol, mirror_port, mirror_user,
|
||||||
|
mirror_password_enc, mirror_base_path FROM settings WHERE id=1`);
|
||||||
|
if (!s?.mirror_enabled || !s?.mirror_host || !s?.mirror_user || !s?.mirror_password_enc) return null;
|
||||||
|
let password = '';
|
||||||
|
try { password = decrypt(s.mirror_password_enc); } catch { return null; }
|
||||||
|
return {
|
||||||
|
host: s.mirror_host, protocol: (s.mirror_protocol || 'sftp'),
|
||||||
|
port: s.mirror_port || (s.mirror_protocol === 'ftps' ? 21 : 22),
|
||||||
|
user: s.mirror_user, password, basePath: s.mirror_base_path || '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testMirror(cfg: MirrorCfg): Promise<{ ok: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||||
|
await c.list(cfg.basePath || '/');
|
||||||
|
await c.end();
|
||||||
|
} else {
|
||||||
|
const { Client } = await import('basic-ftp');
|
||||||
|
const c = new Client(15000);
|
||||||
|
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||||
|
await c.list(cfg.basePath || '/');
|
||||||
|
c.close();
|
||||||
|
}
|
||||||
|
return { ok: true, message: 'Connection to the backup mirror succeeded.' };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, message: e?.message || 'Connection to the backup mirror failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToMirror(cfg: MirrorCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
|
||||||
|
// The same check as for the delivery target, for the same reason:
|
||||||
|
// `posixpath.join` is no protection against `..` — the file name comes out of
|
||||||
|
// the database and is therefore checked here, immediately before writing.
|
||||||
|
const { safeSegment } = await import('./remotetarget');
|
||||||
|
const dir = posixpath.join(cfg.basePath || '/', safeSegment(remoteDir, 'folder'));
|
||||||
|
const finalPath = posixpath.join(dir, safeSegment(filename, 'file name'));
|
||||||
|
const { Readable } = await import('node:stream');
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||||
|
try {
|
||||||
|
if (!(await c.exists(dir))) await c.mkdir(dir, true);
|
||||||
|
await c.put(buf, finalPath);
|
||||||
|
} finally { await c.end(); }
|
||||||
|
} else {
|
||||||
|
const { Client } = await import('basic-ftp');
|
||||||
|
const c = new Client(20000);
|
||||||
|
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||||
|
try {
|
||||||
|
await c.ensureDir(dir);
|
||||||
|
await c.uploadFrom(Readable.from(buf), finalPath);
|
||||||
|
} finally { c.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors a finished item onto the backup mirror (folder structure klarbild/YYYY-MM/). */
|
||||||
|
export async function mirrorItem(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const cfg = await loadMirrorConfig();
|
||||||
|
if (!cfg) return { ok: false, message: 'Backup mirror not configured.' };
|
||||||
|
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||||
|
if (!it?.result_path) return { ok: false, message: 'No result available.' };
|
||||||
|
await query(`UPDATE items SET mirror_status='pending' WHERE id=$1`, [itemId]);
|
||||||
|
try {
|
||||||
|
const buf = await getObject(it.result_path);
|
||||||
|
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // YYYY-MM
|
||||||
|
// Structure: <base folder>/YYYY-MM/<file> — the base folder is freely choosable.
|
||||||
|
await uploadToMirror(cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||||
|
await query(`UPDATE items SET mirror_status='mirrored' WHERE id=$1`, [itemId]);
|
||||||
|
return { ok: true, message: 'Backed up to the mirror.' };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[mirror] mirror', itemId, 'failed:', e?.message || e);
|
||||||
|
await query(`UPDATE items SET mirror_status='failed' WHERE id=$1`, [itemId]);
|
||||||
|
return { ok: false, message: e?.message || 'Backup to the mirror failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Optional modules.
|
||||||
|
*
|
||||||
|
* Klarbild ships as one codebase but not as one product. The print studio is the
|
||||||
|
* core and is always on; everything that needs an outside account — an image model,
|
||||||
|
* a bot, an SMTP mailbox, a remote server to deliver to — is a module you switch on
|
||||||
|
* only if you want it. A print-only install therefore needs no API key, no bot
|
||||||
|
* token and no mailbox, and the user interface simply does not show those parts.
|
||||||
|
*
|
||||||
|
* Configure with the `KLARBILD_MODULES` environment variable, comma separated:
|
||||||
|
*
|
||||||
|
* KLARBILD_MODULES=print # default: the print studio, nothing else
|
||||||
|
* KLARBILD_MODULES=print,ai,share # add image generation and share links
|
||||||
|
* KLARBILD_MODULES=all # everything
|
||||||
|
*
|
||||||
|
* `print` is implied even if you leave it out — there is no build of Klarbild
|
||||||
|
* without it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MODULE_IDS = ['print', 'ai', 'delivery', 'mirror', 'share', 'mail', 'telegram'] as const;
|
||||||
|
export type ModuleId = (typeof MODULE_IDS)[number];
|
||||||
|
|
||||||
|
/** What each module adds, in one line — used by the admin page and by `--help` style output. */
|
||||||
|
export const MODULE_INFO: Record<ModuleId, { title: string; summary: string; needs: string }> = {
|
||||||
|
print: {
|
||||||
|
title: 'Print studio',
|
||||||
|
summary: 'Exact physical sizes, sheet layout with crop marks, tiling, PDF at 1:1.',
|
||||||
|
needs: 'Nothing beyond the database and a volume.',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
title: 'Image generation',
|
||||||
|
summary: 'Clean up, convert, combine and generate images with an image model.',
|
||||||
|
needs: 'An OpenRouter API key (paid, per image).',
|
||||||
|
},
|
||||||
|
delivery: {
|
||||||
|
title: 'Delivery to a remote target',
|
||||||
|
summary: 'Push finished files to an SFTP or FTPS server, plus extra targets.',
|
||||||
|
needs: 'A server you already have, and its credentials.',
|
||||||
|
},
|
||||||
|
mirror: {
|
||||||
|
title: 'Backup mirror',
|
||||||
|
summary: 'Copy every finished file to a second SFTP/FTPS target, sorted by month.',
|
||||||
|
needs: 'A second server or a network drive that speaks SFTP/FTPS.',
|
||||||
|
},
|
||||||
|
share: {
|
||||||
|
title: 'Share links',
|
||||||
|
summary: 'Public, expiring, revocable links to a single image or a whole set.',
|
||||||
|
needs: 'A publicly reachable URL for this instance.',
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
title: 'Email',
|
||||||
|
summary: 'Password reset, job notifications and sending share links by mail.',
|
||||||
|
needs: 'An SMTP mailbox of your own.',
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
title: 'Telegram bot',
|
||||||
|
summary: 'Use Klarbild from a chat instead of the browser.',
|
||||||
|
needs: 'A bot token from @BotFather and a public webhook URL.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parse(raw: string | undefined): Set<ModuleId> {
|
||||||
|
const on = new Set<ModuleId>(['print']); // never optional
|
||||||
|
const value = (raw ?? '').trim();
|
||||||
|
if (!value) return on;
|
||||||
|
if (value.toLowerCase() === 'all') return new Set(MODULE_IDS);
|
||||||
|
for (const piece of value.split(',')) {
|
||||||
|
const id = piece.trim().toLowerCase() as ModuleId;
|
||||||
|
if ((MODULE_IDS as readonly string[]).includes(id)) on.add(id);
|
||||||
|
else if (id) console.warn(`[modules] unknown module "${id}" ignored (known: ${MODULE_IDS.join(', ')})`);
|
||||||
|
}
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = parse(process.env.KLARBILD_MODULES);
|
||||||
|
|
||||||
|
/** True if the module is switched on for this instance. */
|
||||||
|
export function has(id: ModuleId): boolean {
|
||||||
|
return enabled.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The switched-on modules, in declaration order. Safe to send to the browser. */
|
||||||
|
export function activeModules(): ModuleId[] {
|
||||||
|
return MODULE_IDS.filter((id) => enabled.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard for API routes belonging to a module that is off.
|
||||||
|
*
|
||||||
|
* Returning 404 rather than 403 is deliberate: to the outside world a module that
|
||||||
|
* is off does not exist, so a disabled instance leaks nothing about what it could
|
||||||
|
* have done.
|
||||||
|
*/
|
||||||
|
export function moduleOff(id: ModuleId): Response {
|
||||||
|
return new Response(JSON.stringify({ error: `Module "${id}" is not enabled on this instance.` }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// File names for Klarbild results — deliberately **with no dependencies at all**,
|
||||||
|
// so that the rules stay testable and are the same everywhere (job processing,
|
||||||
|
// renaming, tests).
|
||||||
|
//
|
||||||
|
// Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||||
|
// 2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg
|
||||||
|
//
|
||||||
|
// Why: the timestamp comes first, so every file listing — delivery target,
|
||||||
|
// Finder, backup mirror — sorts chronologically by itself. The name becomes
|
||||||
|
// unique through the second **and** a short id derived from the item id. Before,
|
||||||
|
// it was only `YYYY-MM-DD_<motif>_<format>`, and two badges of the same subject
|
||||||
|
// on the same day got the same name — on delivery the second overwrote the first.
|
||||||
|
|
||||||
|
/** Lower case, with hyphens, without umlaut trouble. */
|
||||||
|
export function slugify(s: string): string {
|
||||||
|
return (s || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
|
||||||
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
|
||||||
|
|
||||||
|
/** A name we produced ourselves — timestamp in front, short id at the back.
|
||||||
|
* Needed in order to strip our own stamp again when processing further, and to
|
||||||
|
* recognise whether a name is already in the new scheme. */
|
||||||
|
// All the parts are slugs (a–z, 0–9, hyphen) and therefore never contain an
|
||||||
|
// underscore — so the pattern splits the name unambiguously into five parts.
|
||||||
|
export const KLARBILD_NAME =
|
||||||
|
/^(\d{4}-\d{2}-\d{2})_(\d{6})_([a-z0-9-]+)_([a-z0-9-]+)_([0-9a-z]{4})(?:-(\d+))?$/;
|
||||||
|
|
||||||
|
/** Peel the motif out of any (possibly already processed) name: remove date
|
||||||
|
* prefixes, UUIDs and long hex strings, so that nothing gets duplicated. */
|
||||||
|
function cleanMotif(name: string | null): string {
|
||||||
|
let s = (name || '').replace(/\.[^.]+$/, '');
|
||||||
|
// First strip our own stamp specifically — only if the name really matches our
|
||||||
|
// pattern. Removing every group of six digits across the board would be too
|
||||||
|
// coarse and would take apart real motifs such as "room104235".
|
||||||
|
const m = KLARBILD_NAME.exec(s);
|
||||||
|
if (m) s = m[3];
|
||||||
|
// Work without \b — word boundaries do not bite at underscores (…_2026-07-24…).
|
||||||
|
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
|
||||||
|
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // date (also more than once)
|
||||||
|
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // long hex strings
|
||||||
|
return slugify(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time zone for the stamp in the file name. Deliberately the local time of the
|
||||||
|
* installation — an image taken at 0:30 should not be dated to the previous day
|
||||||
|
* (which is what UTC would do). */
|
||||||
|
const TZ = process.env.KLARBILD_TZ || 'Europe/Berlin';
|
||||||
|
const STAMP_FMT = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "2026-08-20_143207" — sorted lexicographically = sorted chronologically. */
|
||||||
|
export function timeStamp(at: Date = new Date()): string {
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const { type, value } of STAMP_FMT.formatToParts(at)) p[type] = value;
|
||||||
|
// Some Node versions return "24" instead of "00" for midnight.
|
||||||
|
const hh = p.hour === '24' ? '00' : p.hour;
|
||||||
|
return `${p.year}-${p.month}-${p.day}_${hh}${p.minute}${p.second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four-character short id, **derived stably from the item id**.
|
||||||
|
* Stable matters: a second run of the renaming has to give the same name,
|
||||||
|
* otherwise the files move again on every run. */
|
||||||
|
export function shortTag(id?: string | null): string {
|
||||||
|
const hex = String(id || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
||||||
|
if (hex.length >= 4) return hex.slice(0, 4);
|
||||||
|
return Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NameParts {
|
||||||
|
/** Original file name, out of which the motif is peeled. */
|
||||||
|
originalName?: string | null;
|
||||||
|
formatToken?: string;
|
||||||
|
ext?: string;
|
||||||
|
/** Point in time for the stamp — when renaming, the `created_at` of the image. */
|
||||||
|
at?: Date;
|
||||||
|
/** Item id for the stable short id. */
|
||||||
|
id?: string | null;
|
||||||
|
/** Counter, in case the name is taken after all (2, 3, …). */
|
||||||
|
seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique, chronologically sortable file name:
|
||||||
|
* `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||||
|
* e.g. `2026-08-20_143207_portrait_the-frame_a3f9.png`
|
||||||
|
*
|
||||||
|
* Why like this: the name begins with the timestamp, so every file listing
|
||||||
|
* (delivery target, Finder, backup mirror) sorts chronologically by itself. It
|
||||||
|
* becomes unique through the second **and** the short id derived from the item
|
||||||
|
* id — two images of the same subject in the same format on the same day no
|
||||||
|
* longer overwrite each other.
|
||||||
|
*/
|
||||||
|
export function buildResultFilename(
|
||||||
|
originalName: string | null, formatToken: string, ext = 'png', opts: Omit<NameParts, 'originalName' | 'formatToken' | 'ext'> = {},
|
||||||
|
): string {
|
||||||
|
const raw = cleanMotif(originalName);
|
||||||
|
const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
|
||||||
|
const fmt = slugify(formatToken) || 'original';
|
||||||
|
const seq = opts.seq && opts.seq > 1 ? `-${opts.seq}` : '';
|
||||||
|
return `${timeStamp(opts.at)}_${motif}_${fmt}_${shortTag(opts.id)}${seq}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OldName { motif: string; format: string; ext: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an existing name into motif, format and extension — for the renaming, so
|
||||||
|
* that the names already chosen stay and only the timestamp plus short id are
|
||||||
|
* set anew.
|
||||||
|
*/
|
||||||
|
export function splitOldName(filename: string | null): OldName {
|
||||||
|
const raw = String(filename || '');
|
||||||
|
const dot = raw.lastIndexOf('.');
|
||||||
|
const ext = dot > 0 ? raw.slice(dot + 1).toLowerCase() : 'png';
|
||||||
|
const stem = dot > 0 ? raw.slice(0, dot) : raw;
|
||||||
|
|
||||||
|
// Already in the new scheme: take over motif and format unchanged.
|
||||||
|
const already = KLARBILD_NAME.exec(stem);
|
||||||
|
if (already) return { motif: already[3], format: already[4], ext };
|
||||||
|
|
||||||
|
const parts = stem.split('_').filter(Boolean);
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(parts[0] || '')) parts.shift(); // old date prefix
|
||||||
|
const format = parts.length > 1 ? slugify(parts.pop()!) : 'original';
|
||||||
|
const motif = slugify(parts.join('-')) || 'klarbild';
|
||||||
|
return { motif: motif || 'klarbild', format: format || 'original', ext };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name an existing image would have to carry under the new scheme. */
|
||||||
|
export function newNameFor(it: { id: string; filename: string | null; created_at: Date | string }, seq = 1): string {
|
||||||
|
const { motif, format, ext } = splitOldName(it.filename);
|
||||||
|
const at = it.created_at instanceof Date ? it.created_at : new Date(it.created_at);
|
||||||
|
const s = seq > 1 ? `-${seq}` : '';
|
||||||
|
return `${timeStamp(at)}_${motif}_${format}_${shortTag(it.id)}${s}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format key for the file name (from the output_format). */
|
||||||
|
export function formatToken(outputFormat: string | undefined): string {
|
||||||
|
if (!outputFormat || outputFormat === 'keep') return 'original';
|
||||||
|
if (outputFormat === 'theframe') return 'the-frame';
|
||||||
|
if (outputFormat === 'portrait916') return 'portrait916';
|
||||||
|
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Names for print files
|
||||||
|
*
|
||||||
|
* The same rule as for library results, for the same reason: two print sheets on
|
||||||
|
* the same day were both called `2026-09-01_print-sheet.pdf` — the browser
|
||||||
|
* appended "(1)", and whoever put the file on the backup mirror or into a folder
|
||||||
|
* overwrote the first sheet. Exactly the bug that was fixed for the images in
|
||||||
|
* August.
|
||||||
|
* ------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Short token for the paper: "A4" → `a4`, otherwise the size in mm (`210x297`). */
|
||||||
|
export function paperToken(paperId: string | null | undefined, wMm: number, hMm: number): string {
|
||||||
|
const s = slugify(String(paperId || ''));
|
||||||
|
if (s && s.length <= 12) return s;
|
||||||
|
return `${Math.round(wMm)}x${Math.round(hMm)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name for a print sheet:
|
||||||
|
* `2026-09-01_143207_print-9-pieces_a4_4eca.pdf`
|
||||||
|
*
|
||||||
|
* The short id comes from the id of the history entry — so the file can be
|
||||||
|
* matched to an entry under "Last printed".
|
||||||
|
*/
|
||||||
|
export function buildSheetFilename(opts: {
|
||||||
|
/** Name the caller wants, otherwise one is built from pieces and sheets. */
|
||||||
|
name?: string | null;
|
||||||
|
paperId?: string | null;
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
pieces: number; pages: number;
|
||||||
|
runId?: string | null;
|
||||||
|
at?: Date;
|
||||||
|
}): string {
|
||||||
|
const pieces = opts.pieces === 1 ? 'print-1-piece' : `print-${opts.pieces}-pieces`;
|
||||||
|
const sheets = opts.pages > 1 ? `-${opts.pages}-sheets` : '';
|
||||||
|
const motif = slugify(String(opts.name || '')) || `${pieces}${sheets}`;
|
||||||
|
return buildResultFilename(motif, paperToken(opts.paperId, opts.wMm, opts.hMm), 'pdf',
|
||||||
|
{ id: opts.runId, at: opts.at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name for a single image brought to size:
|
||||||
|
* `2026-09-01_143207_portrait-badge_13x18_25b3.jpg`
|
||||||
|
* The motif comes from the source file, the short id from the image id.
|
||||||
|
*/
|
||||||
|
export function buildSingleFilename(opts: {
|
||||||
|
name?: string | null;
|
||||||
|
sourceName?: string | null;
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
ext: string;
|
||||||
|
id?: string | null;
|
||||||
|
at?: Date;
|
||||||
|
}): string {
|
||||||
|
const motif = String(opts.name || '') || String(opts.sourceName || '');
|
||||||
|
const size = `${trimNumber(opts.wMm)}x${trimNumber(opts.hMm)}`;
|
||||||
|
return buildResultFilename(motif, size, opts.ext, { id: opts.id, at: opts.at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 45 → "45", 112.5 → "112-5" (in a file name a dot means the extension). */
|
||||||
|
function trimNumber(n: number): string {
|
||||||
|
return String(Math.round(n * 10) / 10).replace('.', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the file name out of a `Content-Disposition` header.
|
||||||
|
*
|
||||||
|
* The browser gets the name from the server — the client should **not invent one
|
||||||
|
* of its own**, otherwise there are two truths. That is exactly what happened:
|
||||||
|
* the server sent a name, and the interface overwrote it with one of its own
|
||||||
|
* that only contained the date.
|
||||||
|
*/
|
||||||
|
export function filenameFromHeader(header: string | null | undefined, fallback: string): string {
|
||||||
|
const h = String(header || '');
|
||||||
|
// RFC 5987 (filename*=UTF-8''…) takes precedence over the plain filename=.
|
||||||
|
const star = /filename\*=UTF-8''([^;]+)/i.exec(h);
|
||||||
|
if (star) { try { return safe(decodeURIComponent(star[1])) || fallback; } catch { /* carry on below */ } }
|
||||||
|
const plain = /filename="?([^";]+)"?/i.exec(h);
|
||||||
|
if (plain) return safe(plain[1]) || fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name read out of a header must never contain a path. */
|
||||||
|
function safe(n: string): string {
|
||||||
|
return n.replace(/[\\/\r\n\0]/g, '').replace(/^\.+/, '').trim().slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name for a ZIP of a selection from the library:
|
||||||
|
* `2026-09-01_143207_selection-12-images_a3f9.zip`
|
||||||
|
*
|
||||||
|
* Before, every one of them was called `klarbild_2026-09-01.zip` — two different
|
||||||
|
* selections on the same day in the same folder, and the second overwrote the
|
||||||
|
* first.
|
||||||
|
*/
|
||||||
|
export function buildSelectionZipName(count: number, at: Date = new Date()): string {
|
||||||
|
const what = count === 1 ? 'selection-1-image' : `selection-${count}-images`;
|
||||||
|
return `${timeStamp(at)}_${what}_${shortTag(null)}.zip`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { one } from './db';
|
||||||
|
import { decrypt } from './crypto';
|
||||||
|
|
||||||
|
const BASE = 'https://openrouter.ai/api/v1';
|
||||||
|
|
||||||
|
/** Key: the DB value (set in the admin area) takes precedence over the environment variable. */
|
||||||
|
export async function resolveKey(): Promise<string> {
|
||||||
|
const row = await one<{ openrouter_key_enc: string | null }>(
|
||||||
|
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
||||||
|
if (row?.openrouter_key_enc) {
|
||||||
|
try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ }
|
||||||
|
}
|
||||||
|
return process.env.OPENROUTER_API_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenRouterError extends Error {
|
||||||
|
constructor(public status: number, public friendly: string, msg?: string) {
|
||||||
|
super(msg || friendly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyFor(status: number): string {
|
||||||
|
if (status === 402) return 'Credit used up — not possible right now.';
|
||||||
|
if (status === 429) return 'Service overloaded — will be retried automatically.';
|
||||||
|
if (status >= 500) return 'Service temporarily disrupted.';
|
||||||
|
if (status === 401 || status === 403) return 'No access to the image service.';
|
||||||
|
return 'Image processing failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateOpts {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
inputUrl?: string; // one reference image (data URL) — short form
|
||||||
|
inputUrls?: string[]; // several reference images (compose) — up to 14/16 depending on the model
|
||||||
|
aspectRatio?: string; // "16:9", "3:4" …
|
||||||
|
resolution?: string; // "2K" | "4K" — NOT together with explicit pixels
|
||||||
|
background?: 'transparent' | 'opaque';
|
||||||
|
outputFormat?: 'png' | 'jpeg' | 'webp';
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateResult {
|
||||||
|
buffer: Buffer;
|
||||||
|
mediaType: string;
|
||||||
|
cost: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
||||||
|
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
||||||
|
const key = await resolveKey();
|
||||||
|
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No OpenRouter key stored');
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: opts.model,
|
||||||
|
prompt: opts.prompt,
|
||||||
|
n: 1,
|
||||||
|
output_format: opts.outputFormat || 'png',
|
||||||
|
};
|
||||||
|
const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []);
|
||||||
|
if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } }));
|
||||||
|
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
||||||
|
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // never both
|
||||||
|
if (opts.background) body.background = opts.background;
|
||||||
|
if (typeof opts.seed === 'number') body.seed = opts.seed;
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/images`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Title': 'Klarbild',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
||||||
|
`OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: any = await res.json();
|
||||||
|
const b64 = json?.data?.[0]?.b64_json;
|
||||||
|
if (!b64) throw new OpenRouterError(502, 'No image received.', 'Response without b64_json');
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(b64, 'base64'),
|
||||||
|
mediaType: json.data[0].media_type || 'image/png',
|
||||||
|
cost: Number(json?.usage?.cost ?? 0),
|
||||||
|
model: opts.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The available image models (for the model list in the admin area). */
|
||||||
|
export async function listImageModels(): Promise<any[]> {
|
||||||
|
const key = await resolveKey();
|
||||||
|
const res = await fetch(`${BASE}/images/models`, {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
||||||
|
return (await res.json())?.data ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Paper and image sizes for the print sheet (the "Print" module, no AI involved).
|
||||||
|
// All dimensions in millimetres — mm is the leading unit of this module, because a
|
||||||
|
// print sheet is a physical thing. Pixels only come into being at render time (mm → dpi).
|
||||||
|
|
||||||
|
export interface PaperSize { id: string; label: string; w: number; h: number; group: string }
|
||||||
|
|
||||||
|
/** Sheet sizes (portrait convention: short side × long side). */
|
||||||
|
export const PAPERS: PaperSize[] = [
|
||||||
|
// DIN
|
||||||
|
{ id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' },
|
||||||
|
{ id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' },
|
||||||
|
{ id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' },
|
||||||
|
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||||
|
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||||
|
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||||
|
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
|
||||||
|
// Photo paper (cut sheets, as sold over the counter)
|
||||||
|
{ id: 'F9x13', label: 'Photo paper 9 × 13 cm', w: 90, h: 130, group: 'Photo paper' },
|
||||||
|
{ id: 'F10x15', label: 'Photo paper 10 × 15 cm', w: 100, h: 150, group: 'Photo paper' },
|
||||||
|
{ id: 'F13x18', label: 'Photo paper 13 × 18 cm', w: 130, h: 180, group: 'Photo paper' },
|
||||||
|
{ id: 'F15x20', label: 'Photo paper 15 × 20 cm', w: 150, h: 200, group: 'Photo paper' },
|
||||||
|
{ id: 'F20x30', label: 'Photo paper 20 × 30 cm', w: 200, h: 300, group: 'Photo paper' },
|
||||||
|
{ id: 'F10x10', label: 'Photo paper 10 × 10 cm', w: 100, h: 100, group: 'Photo paper' },
|
||||||
|
{ id: 'F13x13', label: 'Photo paper 13 × 13 cm', w: 130, h: 130, group: 'Photo paper' },
|
||||||
|
{ id: 'F4x6', label: 'Photo paper 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Photo paper' },
|
||||||
|
{ id: 'F5x7', label: 'Photo paper 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Photo paper' },
|
||||||
|
{ id: 'F8x10', label: 'Photo paper 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Photo paper' },
|
||||||
|
// US
|
||||||
|
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||||
|
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Image sizes (the trim size of a single image on the sheet). */
|
||||||
|
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||||
|
|
||||||
|
export const PHOTO_SIZES: PhotoSize[] = [
|
||||||
|
// --- Passport / ID photo (national requirements) ---
|
||||||
|
{ id: 'P35x45', label: 'Passport photo 35 × 45 mm (DE/EU, biometric)', w: 35, h: 45, group: 'Passport photo' },
|
||||||
|
{ id: 'P33x48', label: 'China visa 33 × 48 mm', w: 33, h: 48, group: 'Passport photo' },
|
||||||
|
{ id: 'P50x50', label: 'US visa 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passport photo' },
|
||||||
|
{ id: 'P50x70', label: 'Canada passport photo 50 × 70 mm', w: 50, h: 70, group: 'Passport photo' },
|
||||||
|
{ id: 'P45x35', label: 'Passport photo landscape 45 × 35 mm', w: 45, h: 35, group: 'Passport photo' },
|
||||||
|
{ id: 'P25x35', label: 'Small ID 25 × 35 mm', w: 25, h: 35, group: 'Passport photo' },
|
||||||
|
|
||||||
|
// --- Small sizes (nursery, school, wallet) ---
|
||||||
|
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Small' },
|
||||||
|
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Small' },
|
||||||
|
{ id: 'K35x50', label: '3.5 × 5 cm', w: 35, h: 50, group: 'Small' },
|
||||||
|
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Small' },
|
||||||
|
{ id: 'K45x60', label: '4.5 × 6 cm', w: 45, h: 60, group: 'Small' },
|
||||||
|
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Small' },
|
||||||
|
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Small' },
|
||||||
|
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Small' },
|
||||||
|
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Small' },
|
||||||
|
|
||||||
|
// --- Classic photo sizes ---
|
||||||
|
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Photo' },
|
||||||
|
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Photo' },
|
||||||
|
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Photo' },
|
||||||
|
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Photo' },
|
||||||
|
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Photo' },
|
||||||
|
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Photo' },
|
||||||
|
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Photo' },
|
||||||
|
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Photo' },
|
||||||
|
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Photo' },
|
||||||
|
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Photo' },
|
||||||
|
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Photo' },
|
||||||
|
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Photo' },
|
||||||
|
|
||||||
|
// --- Inch sizes (US photo labs, frames bought abroad) ---
|
||||||
|
{ id: 'Z2_5x3_5', label: 'Wallet 2.5 × 3.5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z3_5x5', label: '3.5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Inches (US)' },
|
||||||
|
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Inches (US)' },
|
||||||
|
|
||||||
|
// --- Instant-film look-alikes (image area, not card size) ---
|
||||||
|
{ id: 'I46x62', label: 'Instax mini · image area 46 × 62 mm', w: 46, h: 62, group: 'Instant photo' },
|
||||||
|
{ id: 'I62x62', label: 'Instax Square · image area 62 × 62 mm', w: 62, h: 62, group: 'Instant photo' },
|
||||||
|
{ id: 'I99x62', label: 'Instax Wide · image area 99 × 62 mm', w: 99, h: 62, group: 'Instant photo' },
|
||||||
|
{ id: 'I79x79', label: 'Polaroid · image area 79 × 79 mm', w: 79, h: 79, group: 'Instant photo' },
|
||||||
|
{ id: 'I88x107', label: 'Polaroid · whole card 88 × 107 mm', w: 88, h: 107, group: 'Instant photo' },
|
||||||
|
|
||||||
|
// --- Poster and frame sizes ---
|
||||||
|
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & frames' },
|
||||||
|
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & frames' },
|
||||||
|
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & frames' },
|
||||||
|
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & frames' },
|
||||||
|
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & frames' },
|
||||||
|
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & frames' },
|
||||||
|
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & frames' },
|
||||||
|
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & frames' },
|
||||||
|
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & frames' },
|
||||||
|
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & frames' },
|
||||||
|
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & frames' },
|
||||||
|
|
||||||
|
// --- Squares ---
|
||||||
|
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Square' },
|
||||||
|
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Square' },
|
||||||
|
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Square' },
|
||||||
|
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Square' },
|
||||||
|
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Square' },
|
||||||
|
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Square' },
|
||||||
|
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Square' },
|
||||||
|
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Square' },
|
||||||
|
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Square' },
|
||||||
|
|
||||||
|
// --- DIN ---
|
||||||
|
{ id: 'DA7', label: 'DIN A7 (7.4 × 10.5 cm)', w: 74, h: 105, group: 'DIN' },
|
||||||
|
{ id: 'DA6', label: 'DIN A6 (10.5 × 14.8 cm)', w: 105, h: 148, group: 'DIN' },
|
||||||
|
{ id: 'DA5', label: 'DIN A5 (14.8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
||||||
|
{ id: 'DA4', label: 'DIN A4 (21 × 29.7 cm)', w: 210, h: 297, group: 'DIN' },
|
||||||
|
{ id: 'DA3', label: 'DIN A3 (29.7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||||
|
{ id: 'DA3P', label: 'DIN A3+ (32.9 × 48.3 cm)', w: 329, h: 483, group: 'DIN' },
|
||||||
|
{ id: 'DA2', label: 'DIN A2 (42 × 59.4 cm)', w: 420, h: 594, group: 'DIN' },
|
||||||
|
{ id: 'DA1', label: 'DIN A1 (59.4 × 84.1 cm)', w: 594, h: 841, group: 'DIN' },
|
||||||
|
{ id: 'DA0', label: 'DIN A0 (84.1 × 118.9 cm)', w: 841, h: 1189, group: 'DIN' },
|
||||||
|
|
||||||
|
// --- Cards and printed matter ---
|
||||||
|
{ id: 'C85x55', label: 'Business card 85 × 55 mm', w: 85, h: 55, group: 'Cards' },
|
||||||
|
{ id: 'C148x105', label: 'Postcard 14.8 × 10.5 cm (A6 landscape)', w: 148, h: 105, group: 'Cards' },
|
||||||
|
{ id: 'C99x210', label: 'DL 9.9 × 21 cm', w: 99, h: 210, group: 'Cards' },
|
||||||
|
{ id: 'C210x99', label: 'DL landscape 21 × 9.9 cm', w: 210, h: 99, group: 'Cards' },
|
||||||
|
{ id: 'C54x86', label: 'Credit card 85.6 × 54 mm', w: 85.6, h: 54, group: 'Cards' },
|
||||||
|
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
||||||
|
|
||||||
|
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
||||||
|
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W3x2', label: '3:2 (30 cm wide)', w: 300, h: 200, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W2x3', label: '2:3 (30 cm tall)', w: 200, h: 300, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W4x3', label: '4:3 (30 cm wide)', w: 300, h: 225, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W3x4', label: '3:4 (30 cm tall)', w: 225, h: 300, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W5x4', label: '5:4 (30 cm wide)', w: 300, h: 240, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W4x5', label: '4:5 Instagram (30 cm tall)', w: 240, h: 300, group: 'Aspect ratio' },
|
||||||
|
{ id: 'W1x1', label: '1:1 square (30 cm)', w: 300, h: 300, group: 'Aspect ratio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||||
|
export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-text size → mm. Deliberately generous, because people type sizes the way they say them:
|
||||||
|
* "12x15" → 120 × 150 mm (cm is the default unit)
|
||||||
|
* "12 × 15 cm" → 120 × 150 mm
|
||||||
|
* "35x45mm" → 35 × 45 mm
|
||||||
|
* "5" → 50 × 50 mm (square)
|
||||||
|
* "4:3 / 15" → aspect ratio 4:3, longer side 15 cm → 150 × 112.5 mm
|
||||||
|
*/
|
||||||
|
export function parseSizeMm(input: string): { w: number; h: number } | null {
|
||||||
|
const t = (input || '').trim().toLowerCase().replace(/,/g, '.');
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
|
// Aspect ratio with a target edge: "4:3 / 15" or "4:3 15cm"
|
||||||
|
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||||
|
if (ratio && ratio[3]) {
|
||||||
|
// a:b is read as width:height — so "3:4/15" is portrait, "4:3/15" landscape.
|
||||||
|
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||||
|
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||||
|
if (!a || !b || !long) return null;
|
||||||
|
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = /mm\s*$/.test(t) ? 1 : 10; // no unit given: cm
|
||||||
|
const body = t.replace(/\s*(mm|cm)\s*$/, '').trim();
|
||||||
|
|
||||||
|
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body);
|
||||||
|
if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit);
|
||||||
|
|
||||||
|
const sq = /^(\d+(?:\.\d+)?)$/.exec(body);
|
||||||
|
if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(w: number, h: number): { w: number; h: number } | null {
|
||||||
|
const r = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null;
|
||||||
|
return { w: r(w), h: r(h) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||||
|
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
||||||
|
|
||||||
|
/** Readable caption for a size — without losing the real decimal places. */
|
||||||
|
export function labelMm(w: number, h: number): string {
|
||||||
|
const mm = (n: number) => dec(Math.round(n * 10) / 10);
|
||||||
|
const cm = (n: number) => dec(Math.round(n * 100) / 1000);
|
||||||
|
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
|
||||||
|
}
|
||||||
|
// Decimal separator of the caption. A point, i.e. English notation. The output
|
||||||
|
// of `labelMm` is assembled at run time out of numbers, so it cannot go through
|
||||||
|
// the dictionary — whatever is written here is what every instance shows, and an
|
||||||
|
// English instance must not print "11,25 × 15 cm". `tests/printlayout.test.ts`
|
||||||
|
// pins the spelling.
|
||||||
|
const dec = (n: number) => String(n);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import type { Dimensions } from './format';
|
||||||
|
|
||||||
|
export type CropMode = 'crop' | 'extend';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings a (model) result exactly onto the target size and writes dpi metadata.
|
||||||
|
* target=null → keep the original (only set the dpi). Keeps alpha if there is any.
|
||||||
|
*/
|
||||||
|
export async function finalizeToFormat(
|
||||||
|
buffer: Buffer,
|
||||||
|
target: Dimensions | null,
|
||||||
|
cropMode: CropMode,
|
||||||
|
dpi: number,
|
||||||
|
): Promise<{ buffer: Buffer; width: number; height: number; hasAlpha: boolean }> {
|
||||||
|
let img = sharp(buffer, { failOn: 'none' });
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const hasAlpha = !!meta.hasAlpha;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
img = sharp(buffer, { failOn: 'none' }).resize(target.w, target.h, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: cropMode === 'crop' ? sharp.strategy.attention : 'centre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await img
|
||||||
|
.withMetadata({ density: dpi })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
return { buffer: out.data, width: out.info.width, height: out.info.height, hasAlpha };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A white sticker border around the cut-out subject (without the model).
|
||||||
|
* Extract the alpha → dilation (blur+threshold) → white contour layer → the original on top.
|
||||||
|
* Radius = contourMm / 25.4 * dpi.
|
||||||
|
*/
|
||||||
|
export async function stickerContour(
|
||||||
|
buffer: Buffer,
|
||||||
|
contourMm: number,
|
||||||
|
dpi: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const base = sharp(buffer, { failOn: 'none' }).ensureAlpha();
|
||||||
|
const { width, height } = await base.metadata();
|
||||||
|
if (!width || !height) throw new Error('Image dimensions unknown');
|
||||||
|
|
||||||
|
const radiusPx = Math.max(1, Math.round((contourMm / 25.4) * dpi));
|
||||||
|
|
||||||
|
// The alpha as a greyscale mask, widened by blur+threshold (dilation).
|
||||||
|
const mask = await sharp(buffer, { failOn: 'none' })
|
||||||
|
.ensureAlpha()
|
||||||
|
.extractChannel('alpha')
|
||||||
|
.blur(Math.max(0.3, radiusPx / 2))
|
||||||
|
.threshold(1)
|
||||||
|
.toColourspace('b-w')
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// A white layer with the widened mask as its alpha.
|
||||||
|
const whiteLayer = await sharp({
|
||||||
|
create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||||
|
})
|
||||||
|
.joinChannel(mask, { raw: { width, height, channels: 1 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Lay the original (RGBA) over the contour layer.
|
||||||
|
const original = await base.png().toBuffer();
|
||||||
|
return sharp(whiteLayer)
|
||||||
|
.composite([{ input: original }])
|
||||||
|
.withMetadata({ density: dpi })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HEIC/HEIF → PNG buffer (in a standard build sharp usually cannot read HEIC). */
|
||||||
|
export async function heicToPng(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const convert = (await import('heic-convert')).default as any;
|
||||||
|
const out = await convert({ buffer, format: 'PNG' });
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
// Pure layout mathematics for the print sheet — mm, no I/O, no dependencies.
|
||||||
|
// Used identically by the preview in the browser and by the PDF renderer on the
|
||||||
|
// server, so that "what I see" and "what gets printed" are the same calculation.
|
||||||
|
//
|
||||||
|
// Coordinates: origin at the top left, x to the right, y downwards (as on screen/SVG).
|
||||||
|
// The PDF renderer mirrors y, because PDF counts from the bottom left.
|
||||||
|
|
||||||
|
export interface PlaceSpec {
|
||||||
|
id: string; // points at an image
|
||||||
|
wMm: number; // trim size (trim box) width
|
||||||
|
hMm: number; // trim size height
|
||||||
|
count: number; // how many times onto the paper
|
||||||
|
allowRotate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetSpec {
|
||||||
|
wMm: number;
|
||||||
|
hMm: number;
|
||||||
|
marginMm: number; // the printer's non-printable margin (safety margin)
|
||||||
|
gapMm: number; // distance between two trim sizes
|
||||||
|
bleedMm?: number; // bleed per side (the image reaches beyond)
|
||||||
|
center?: boolean; // centre the block on the sheet (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Placement {
|
||||||
|
specId: string;
|
||||||
|
copy: number; // 1-based
|
||||||
|
x: number; y: number; // top left corner of the trim box
|
||||||
|
w: number; h: number; // trim box
|
||||||
|
rotated: boolean; // image placed rotated by 90°
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Line { x1: number; y1: number; x2: number; y2: number }
|
||||||
|
|
||||||
|
export interface Page { placements: Placement[] }
|
||||||
|
|
||||||
|
export interface LayoutResult {
|
||||||
|
pages: Page[];
|
||||||
|
/** What does not fit on any sheet (e.g. image larger than the paper). */
|
||||||
|
unplaced: { specId: string; count: number; reason: string }[];
|
||||||
|
perSheet: number; // pieces on the first sheet (a figure for the UI)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkMode = 'none' | 'corner' | 'grid';
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
/** Effective gap: the bleeds of two neighbours must not overlap. */
|
||||||
|
export function effectiveGap(sheet: SheetSpec): number {
|
||||||
|
return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packs the trim sizes onto as few sheets as possible.
|
||||||
|
*
|
||||||
|
* Two routes, deliberately kept apart:
|
||||||
|
* 1. All images the same size → exact grid (columns × rows). Perfectly
|
||||||
|
* aligned, so that continuous cut lines really do run through.
|
||||||
|
* 2. Mixed sizes → MaxRects (best-short-side-fit) with optional
|
||||||
|
* 90° rotation. This is the case "one large image plus eight passport
|
||||||
|
* photos": the small ones move into the leftover area next to the large one.
|
||||||
|
*
|
||||||
|
* The gap is carried along as an "inflated" rectangle (image + gap), which is
|
||||||
|
* why the spacing is right everywhere, including at the edge.
|
||||||
|
*/
|
||||||
|
export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||||
|
const gap = effectiveGap(sheet);
|
||||||
|
const bleed = sheet.bleedMm || 0;
|
||||||
|
const inset = (sheet.marginMm || 0) + bleed;
|
||||||
|
const availW = sheet.wMm - 2 * inset;
|
||||||
|
const availH = sheet.hMm - 2 * inset;
|
||||||
|
|
||||||
|
const unplaced: LayoutResult['unplaced'] = [];
|
||||||
|
const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = [];
|
||||||
|
|
||||||
|
for (const s of specs) {
|
||||||
|
const n = Math.max(0, Math.floor(s.count || 0));
|
||||||
|
if (!n) continue;
|
||||||
|
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
||||||
|
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||||
|
if (!fitsPlain && !fitsRot) {
|
||||||
|
unplaced.push({ specId: s.id, count: n, reason: 'larger than the usable area of the sheet' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false });
|
||||||
|
}
|
||||||
|
if (!units.length) return { pages: [], unplaced, perSheet: 0 };
|
||||||
|
|
||||||
|
const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS);
|
||||||
|
let pages: Page[];
|
||||||
|
|
||||||
|
if (uniform) {
|
||||||
|
pages = gridPack(units, specs, availW, availH, gap);
|
||||||
|
} else {
|
||||||
|
// Decide the orientation per image size (not per copy) — otherwise identical
|
||||||
|
// images end up lying every which way on the sheet. Try all sensible
|
||||||
|
// combinations and take the best one.
|
||||||
|
const ids = [...new Set(units.map((u) => u.specId))];
|
||||||
|
const rotatable = ids.filter((id) => {
|
||||||
|
const sp = specs.find((s) => s.id === id)!;
|
||||||
|
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||||
|
});
|
||||||
|
const tooBig = ids.length > 5 || units.length > 150;
|
||||||
|
// With very many sizes/pieces, do not try every combination — but still give
|
||||||
|
// every size an orientation in which it fits at all.
|
||||||
|
const variants: Record<string, boolean>[] = tooBig
|
||||||
|
? [Object.fromEntries(ids.map((id) => {
|
||||||
|
const sp = specs.find((x) => x.id === id)!;
|
||||||
|
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
|
||||||
|
return [id, !plainFits && sp.allowRotate !== false];
|
||||||
|
}))]
|
||||||
|
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||||
|
|
||||||
|
let best: Page[] | null = null, bestScore = -Infinity;
|
||||||
|
for (const [vi, rotMap] of variants.entries()) {
|
||||||
|
const oriented = units.map((u) => {
|
||||||
|
const r = !!rotMap[u.specId];
|
||||||
|
const sp = specs.find((s) => s.id === u.specId)!;
|
||||||
|
// Only allow orientations that fit onto the sheet at all.
|
||||||
|
const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm;
|
||||||
|
return { ...u, w, h, rot: r };
|
||||||
|
});
|
||||||
|
if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue;
|
||||||
|
const cand = maxRectsPack(oriented, availW, availH, gap);
|
||||||
|
const placed = cand.reduce((n, p) => n + p.placements.length, 0);
|
||||||
|
if (placed < units.length) continue;
|
||||||
|
// Few sheets counts for most, then a full first page, then "as little
|
||||||
|
// rotated as possible" (the orientation the user asked for is more likely
|
||||||
|
// to survive).
|
||||||
|
const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi
|
||||||
|
- 0.1 * Object.values(rotMap).filter(Boolean).length;
|
||||||
|
if (score > bestScore) { bestScore = score; best = cand; }
|
||||||
|
}
|
||||||
|
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||||
|
// Count the missing pieces per image size — do not blame them all on the first one.
|
||||||
|
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
|
||||||
|
const missing = new Map<string, number>();
|
||||||
|
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
|
||||||
|
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'no room on the sheet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align on the sheet: centre of the block, or top left corner.
|
||||||
|
for (const pg of pages) {
|
||||||
|
let offX = inset, offY = inset;
|
||||||
|
if (sheet.center !== false && pg.placements.length) {
|
||||||
|
const minX = Math.min(...pg.placements.map((p) => p.x));
|
||||||
|
const maxX = Math.max(...pg.placements.map((p) => p.x + p.w));
|
||||||
|
const minY = Math.min(...pg.placements.map((p) => p.y));
|
||||||
|
const maxY = Math.max(...pg.placements.map((p) => p.y + p.h));
|
||||||
|
offX = inset + (availW - (maxX - minX)) / 2 - minX;
|
||||||
|
offY = inset + (availH - (maxY - minY)) / 2 - minY;
|
||||||
|
}
|
||||||
|
for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Images of equal size: a clean grid, row by row. */
|
||||||
|
function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[],
|
||||||
|
availW: number, availH: number, gap: number): Page[] {
|
||||||
|
const spec = specs.find((s) => s.id === units[0].specId);
|
||||||
|
let { w, h } = units[0];
|
||||||
|
let rot = false;
|
||||||
|
const fit = (a: number, b: number) => ({
|
||||||
|
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
|
||||||
|
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
||||||
|
});
|
||||||
|
// The orientation chosen by the user takes precedence. It is rotated only if
|
||||||
|
// that actually lowers the number of sheets (or if nothing fits unrotated).
|
||||||
|
let best = fit(w, h);
|
||||||
|
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||||
|
const alt = fit(h, w);
|
||||||
|
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
|
||||||
|
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
|
||||||
|
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
|
||||||
|
}
|
||||||
|
const perPage = best.cols * best.rows;
|
||||||
|
if (!perPage) return [];
|
||||||
|
|
||||||
|
const pages: Page[] = [];
|
||||||
|
for (let i = 0; i < units.length; i += perPage) {
|
||||||
|
const chunk = units.slice(i, i + perPage);
|
||||||
|
const placements: Placement[] = chunk.map((u, k) => {
|
||||||
|
const c = k % best.cols, r = Math.floor(k / best.cols);
|
||||||
|
return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot };
|
||||||
|
});
|
||||||
|
pages.push({ placements });
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FreeRect { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
/** Mixed sizes: MaxRects with best-short-side-fit, orientation already decided. */
|
||||||
|
function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[],
|
||||||
|
availW: number, availH: number, gap: number): Page[] {
|
||||||
|
// Large ones first — otherwise the small pieces block the good areas.
|
||||||
|
const todo = [...units].sort((a, b) =>
|
||||||
|
(b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) ||
|
||||||
|
a.specId.localeCompare(b.specId) || a.copy - b.copy);
|
||||||
|
|
||||||
|
const W = availW + gap, H = availH + gap; // inflated area
|
||||||
|
const pages: Page[] = [];
|
||||||
|
// Emergency brake against degenerate input (many sizes × many pieces): the
|
||||||
|
// packer runs synchronously in the server process and must not block it.
|
||||||
|
let steps = 0;
|
||||||
|
const MAX_STEPS = 2_000_000;
|
||||||
|
|
||||||
|
while (todo.length) {
|
||||||
|
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
||||||
|
const placements: Placement[] = [];
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity;
|
||||||
|
let bestRect: FreeRect | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < todo.length; i++) {
|
||||||
|
const u = todo[i];
|
||||||
|
const cw = u.w + gap, ch = u.h + gap;
|
||||||
|
if ((steps += free.length) > MAX_STEPS) break;
|
||||||
|
for (const r of free) {
|
||||||
|
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||||
|
const short = Math.min(r.w - cw, r.h - ch);
|
||||||
|
// On a tie, take the area furthest to the top/left — that gives a
|
||||||
|
// calm, readable sheet instead of scattered subjects.
|
||||||
|
const pos = r.y * 10000 + r.x;
|
||||||
|
if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) {
|
||||||
|
bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
|
||||||
|
|
||||||
|
const u = todo.splice(bestIdx, 1)[0];
|
||||||
|
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
|
||||||
|
free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placements.length) break; // nothing placeable → stop
|
||||||
|
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
pages.push({ placements });
|
||||||
|
if (pages.length > 200 || steps > MAX_STEPS) break; // emergency brake
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All subsets (orientation combinations), stably sorted. */
|
||||||
|
function combos(ids: string[]): Set<string>[] {
|
||||||
|
const out: Set<string>[] = [];
|
||||||
|
const n = Math.min(ids.length, 8);
|
||||||
|
for (let m = 0; m < (1 << n); m++) {
|
||||||
|
const s = new Set<string>();
|
||||||
|
for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
// First "nothing rotated", then ascending by number of rotations.
|
||||||
|
return out.sort((a, b) => a.size - b.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
|
||||||
|
const out: FreeRect[] = [];
|
||||||
|
for (const r of free) {
|
||||||
|
const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS
|
||||||
|
&& used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS;
|
||||||
|
if (!overlap) { out.push(r); continue; }
|
||||||
|
if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h });
|
||||||
|
if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h });
|
||||||
|
if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y });
|
||||||
|
if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) });
|
||||||
|
}
|
||||||
|
// Throw away rectangles contained in others (otherwise the list grows without bound).
|
||||||
|
return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) =>
|
||||||
|
j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS &&
|
||||||
|
b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS &&
|
||||||
|
(b.w > a.w + EPS || b.h > a.h + EPS || j < i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many trim sizes fit onto one sheet at most? (A figure for the UI.) */
|
||||||
|
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||||
|
// Calculate directly instead of packing — otherwise the trial run caps the answer.
|
||||||
|
const gap = effectiveGap(sheet);
|
||||||
|
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||||
|
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||||
|
const grid = (w: number, h: number) =>
|
||||||
|
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
|
||||||
|
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
|
||||||
|
const plain = grid(spec.wMm, spec.hMm);
|
||||||
|
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
|
||||||
|
return Math.max(plain, rot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkOptions {
|
||||||
|
mode: MarkMode;
|
||||||
|
lengthMm?: number; // length of one mark (standard: 4–10 mm)
|
||||||
|
offsetMm?: number; // distance outwards from the trim size
|
||||||
|
bleedMm?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop marks as line segments.
|
||||||
|
* - `corner`: four pairs of corner marks per image, outside the trim size (the
|
||||||
|
* Adobe / print-shop convention).
|
||||||
|
* - `grid`: continuous lines across the whole sheet at every cut edge — for the
|
||||||
|
* cutting rule or the guillotine, when there are many identical
|
||||||
|
* images on the sheet.
|
||||||
|
*/
|
||||||
|
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||||
|
if (opt.mode === 'none') return [];
|
||||||
|
const len = opt.lengthMm ?? 4;
|
||||||
|
// The offset has to clear the printed bleed — otherwise the mark lands on the
|
||||||
|
// subject. A requested value that is too small is therefore raised.
|
||||||
|
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
|
||||||
|
const lines: Line[] = [];
|
||||||
|
|
||||||
|
if (opt.mode === 'corner') {
|
||||||
|
for (const p of page.placements) {
|
||||||
|
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
|
||||||
|
// per corner: one horizontal and one vertical mark, pointing outwards
|
||||||
|
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
|
||||||
|
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
|
||||||
|
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clip(lines, sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// grid: draw the cut edges across the whole sheet — but only where no other
|
||||||
|
// image is in the way. Otherwise the line would run right through the
|
||||||
|
// neighbouring subject.
|
||||||
|
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
|
||||||
|
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
|
||||||
|
for (const x of xs) {
|
||||||
|
const blocked = page.placements
|
||||||
|
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
|
||||||
|
.map((p) => [p.y, p.y + p.h] as [number, number]);
|
||||||
|
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
|
||||||
|
}
|
||||||
|
for (const y of ys) {
|
||||||
|
const blocked = page.placements
|
||||||
|
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
|
||||||
|
.map((p) => [p.x, p.x + p.w] as [number, number]);
|
||||||
|
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
|
||||||
|
}
|
||||||
|
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** Free stretches in [from,to] after subtracting the occupied intervals. */
|
||||||
|
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
|
||||||
|
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
let cur = from;
|
||||||
|
for (const [a, b] of sorted) {
|
||||||
|
if (a > cur) out.push([cur, Math.min(a, to)]);
|
||||||
|
cur = Math.max(cur, b);
|
||||||
|
if (cur >= to) break;
|
||||||
|
}
|
||||||
|
if (cur < to) out.push([cur, to]);
|
||||||
|
return out.filter(([a, b]) => b - a > 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest gap at which the chosen marks still make sense:
|
||||||
|
* the corner marks of two neighbours each need (offset + length) of room, and
|
||||||
|
* bleeds must not overlap.
|
||||||
|
*/
|
||||||
|
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
|
||||||
|
const off = offsetMm ?? Math.max(2, bleedMm + 1);
|
||||||
|
const base = 2 * bleedMm;
|
||||||
|
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
|
||||||
|
if (mode === 'grid') return Math.max(base, 0);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||||
|
|
||||||
|
/** Cut back, or drop, marks that would reach beyond the edge of the sheet. */
|
||||||
|
function clip(lines: Line[], sheet: SheetSpec): Line[] {
|
||||||
|
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
|
||||||
|
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
|
||||||
|
return lines
|
||||||
|
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
|
||||||
|
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the source resolution enough for the trim size at the wanted dpi? */
|
||||||
|
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
|
||||||
|
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
|
||||||
|
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Oversize images: what to do when the trim size is larger than the sheet?
|
||||||
|
// 20 × 30 cm on A4 photo paper is the everyday case — exactly 3 mm are missing.
|
||||||
|
// Pure mathematics, no dependencies (like printlayout.ts), so that browser and
|
||||||
|
// server use the same calculation.
|
||||||
|
import type { Placement, SheetSpec } from './printlayout.ts';
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||||
|
|
||||||
|
export type Oversize =
|
||||||
|
| 'fit' // do nothing, report it as "does not fit" (default)
|
||||||
|
| 'overflow' // place it centred on the sheet, the oversize is cut off in printing
|
||||||
|
| 'tile'; // spread it over several sheets (poster print, to be glued together)
|
||||||
|
|
||||||
|
/** Does the trim size fit onto the usable area of the sheet — rotated if need be? */
|
||||||
|
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
|
||||||
|
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||||
|
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||||
|
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
|
||||||
|
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
|
||||||
|
return plain || rot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverflowInfo {
|
||||||
|
/** Placement on the sheet — may be negative, in which case the image reaches beyond. */
|
||||||
|
placement: Placement;
|
||||||
|
rotated: boolean;
|
||||||
|
/** How much is lost per side (mm), worked out after rotating. */
|
||||||
|
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
|
||||||
|
lostMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place an image that is too large centred on the **whole sheet** — deliberately
|
||||||
|
* not on the usable area, because otherwise you give away the margin that a
|
||||||
|
* borderless printer can very well print on. Whatever reaches beyond the sheet is
|
||||||
|
* missing afterwards; how much, is in `lost*`.
|
||||||
|
*/
|
||||||
|
export function overflowPlacement(
|
||||||
|
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
|
||||||
|
): OverflowInfo {
|
||||||
|
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
|
||||||
|
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
|
||||||
|
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
|
||||||
|
|
||||||
|
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
|
||||||
|
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
|
||||||
|
const lostRight = round(Math.max(0, x + w - sheet.wMm));
|
||||||
|
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
|
||||||
|
return {
|
||||||
|
placement: { specId, copy, x, y, w, h, rotated },
|
||||||
|
rotated, lostLeft, lostRight, lostTop, lostBottom,
|
||||||
|
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In a poster print every sheet carries "Sheet 2/4 · row 1, column 2" — that is
|
||||||
|
* the assembly instruction and is therefore always printed (8 pt from 3 mm below
|
||||||
|
* the top edge, needs about 6 mm). If the paper margin is tighter than that, the
|
||||||
|
* tile has to move down accordingly; so that it does not stick out at the bottom,
|
||||||
|
* the sheet is calculated correspondingly lower. Browser and server use the same
|
||||||
|
* function, otherwise the preview would show a different number of sheets than
|
||||||
|
* the finished PDF.
|
||||||
|
*/
|
||||||
|
export const NOTE_MM = 6;
|
||||||
|
export function noteInset(sheet: SheetSpec): number {
|
||||||
|
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
|
||||||
|
}
|
||||||
|
export function tileSheet(sheet: SheetSpec): SheetSpec {
|
||||||
|
const n = noteInset(sheet);
|
||||||
|
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
col: number; row: number; // 0-based
|
||||||
|
xMm: number; yMm: number; // section of the target image, from the top left corner
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
glueRight: boolean; glueBottom: boolean; // a neighbouring tile joins on there
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poster print: spread one image over several sheets.
|
||||||
|
* The tiles overlap by `overlapMm` — that strip is the glue flap. It is printed
|
||||||
|
* on both neighbouring sheets; when assembling, you lay the one sheet onto the
|
||||||
|
* other along the dashed line.
|
||||||
|
*/
|
||||||
|
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
|
||||||
|
const inset = sheet.marginMm || 0;
|
||||||
|
const availW = Math.max(10, sheet.wMm - 2 * inset);
|
||||||
|
const availH = Math.max(10, sheet.hMm - 2 * inset);
|
||||||
|
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
|
||||||
|
|
||||||
|
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
|
||||||
|
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
|
||||||
|
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
|
||||||
|
|
||||||
|
const tiles: Tile[] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const x = c * stepX, y = r * stepY;
|
||||||
|
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
|
||||||
|
if (w <= 0.5 || h <= 0.5) continue;
|
||||||
|
tiles.push({
|
||||||
|
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
|
||||||
|
glueRight: c < cols - 1, glueBottom: r < rows - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cols, rows, tiles, overlapMm: round(o) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The section of one tile, nested inside the user's crop. */
|
||||||
|
export function tileCrop(
|
||||||
|
crop: { x: number; y: number; w: number; h: number },
|
||||||
|
tile: Tile, wMm: number, hMm: number,
|
||||||
|
): { x: number; y: number; w: number; h: number } {
|
||||||
|
return {
|
||||||
|
x: crop.x + (tile.xMm / wMm) * crop.w,
|
||||||
|
y: crop.y + (tile.yMm / hMm) * crop.h,
|
||||||
|
w: (tile.wMm / wMm) * crop.w,
|
||||||
|
h: (tile.hMm / hMm) * crop.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
|
||||||
|
// Deliberately without AI — plain geometry and scaling, so that the result is
|
||||||
|
// predictable and repeatable.
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||||
|
import { mmToPt, mmToPx } from './paper.ts';
|
||||||
|
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
||||||
|
import { baseCrop, type CropRel, type FitMode } from './cropmath.ts';
|
||||||
|
|
||||||
|
/** Crop relative to the source (0..1) — storable independently of resolution. */
|
||||||
|
export type { CropRel, FitMode };
|
||||||
|
|
||||||
|
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
|
||||||
|
|
||||||
|
/* `FitMode`: `cover` = crop until filled (something is missing at the outside),
|
||||||
|
`contain` = fit the whole image in (the letterbox colour stays at the outside).
|
||||||
|
Never distort. */
|
||||||
|
|
||||||
|
/** Largest centred crop in the target ratio — fills it out, cuts something off. */
|
||||||
|
// Both entry points calculate via `src/lib/cropmath.ts` — the same file the
|
||||||
|
// browser preview uses. `tests/cropmath.test.ts` holds the two routes against
|
||||||
|
// each other, so that preview and PDF cannot drift apart.
|
||||||
|
export const coverCrop = (natW: number, natH: number, aspect: number): CropRel =>
|
||||||
|
baseCrop(natW, natH, aspect, 'cover');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest crop in the target ratio that contains the **whole** image.
|
||||||
|
* It deliberately reaches beyond the edge of the image (w or h > 1) — that is
|
||||||
|
* where the letterbox area appears.
|
||||||
|
*/
|
||||||
|
export const containCrop = (natW: number, natH: number, aspect: number): CropRel =>
|
||||||
|
baseCrop(natW, natH, aspect, 'contain');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings a section of an image to an exact physical size.
|
||||||
|
* `bleedMm` enlarges the rendered field outwards; the trim box stays exactly the
|
||||||
|
* chosen section. Missing edge pixels are continued from the edge
|
||||||
|
* (extendWith 'copy') instead of squashing the subject.
|
||||||
|
*/
|
||||||
|
export async function renderCell(
|
||||||
|
input: Buffer,
|
||||||
|
crop: CropRel | null,
|
||||||
|
wMm: number,
|
||||||
|
hMm: number,
|
||||||
|
dpi: number,
|
||||||
|
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
|
||||||
|
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
|
||||||
|
const bleed = Math.max(0, opt.bleedMm || 0);
|
||||||
|
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
|
||||||
|
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
|
||||||
|
|
||||||
|
// Apply the EXIF orientation BEFORE anything is calculated. Phone photos carry
|
||||||
|
// the rotation only as metadata; browsers and the iPhone show them rotated,
|
||||||
|
// while sharp without this step calculates on the unrotated raster — and then
|
||||||
|
// the image comes out of the printer sideways and with the wrong crop.
|
||||||
|
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
|
||||||
|
const src = (meta0.orientation ?? 1) > 1
|
||||||
|
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
|
||||||
|
: input;
|
||||||
|
|
||||||
|
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
|
||||||
|
const nw = meta.width || 0, nh = meta.height || 0;
|
||||||
|
if (!nw || !nh) throw new Error('Image dimensions unknown.');
|
||||||
|
|
||||||
|
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
|
||||||
|
? containCrop(nw, nh, wMm / hMm)
|
||||||
|
: coverCrop(nw, nh, wMm / hMm));
|
||||||
|
|
||||||
|
// Bleed: widen the crop from the centre, so that the trim box stays exactly
|
||||||
|
// the framed area.
|
||||||
|
const fx = (wMm + 2 * bleed) / wMm;
|
||||||
|
const fy = (hMm + 2 * bleed) / hMm;
|
||||||
|
const cw = c.w * fx, ch = c.h * fy;
|
||||||
|
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
|
||||||
|
|
||||||
|
const targetW = mmToPx(wMm + 2 * bleed, dpi);
|
||||||
|
const targetH = mmToPx(hMm + 2 * bleed, dpi);
|
||||||
|
if (targetW * targetH > MAX_TARGET_PX)
|
||||||
|
throw new Error(`Target image too large (${targetW}×${targetH} px). Please reduce the size or the resolution.`);
|
||||||
|
|
||||||
|
// Visible part of the crop (the rest is filled, not rendered).
|
||||||
|
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
|
||||||
|
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
|
||||||
|
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Crop lies outside the image.');
|
||||||
|
|
||||||
|
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
|
||||||
|
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
|
||||||
|
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
|
||||||
|
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
|
||||||
|
|
||||||
|
// Mapping crop → target image (scale in px per fraction of the crop).
|
||||||
|
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
|
||||||
|
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
|
||||||
|
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
|
||||||
|
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
|
||||||
|
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
|
||||||
|
const right = targetW - dx - dw, bottom = targetH - dy - dh;
|
||||||
|
|
||||||
|
// A single sharp chain: extract → resize → extend. That order is sharp's own
|
||||||
|
// internal order, which is why the numbers add up. Important: only pad after
|
||||||
|
// scaling down, otherwise the intermediate image grows beyond all measure.
|
||||||
|
let img = sharp(src, { failOn: 'none' })
|
||||||
|
.extract({ left: L, top: T, width: W, height: H })
|
||||||
|
.resize(dw, dh, { fit: 'fill' });
|
||||||
|
if (dx || dy || right || bottom) {
|
||||||
|
img = img.extend(fit === 'contain'
|
||||||
|
? { top: dy, left: dx, bottom, right, background: bg }
|
||||||
|
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAlpha = !!meta.hasAlpha;
|
||||||
|
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||||
|
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
||||||
|
|
||||||
|
const encode = (pipe: sharp.Sharp) => (ext === 'png'
|
||||||
|
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||||
|
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
||||||
|
|
||||||
|
// Rotate only in the second pass: otherwise sharp would apply a rotation
|
||||||
|
// before the padding and put the margins on the wrong sides.
|
||||||
|
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper bound for the target image — guards against a memory explosion from extreme size/dpi combinations. */
|
||||||
|
const MAX_TARGET_PX = 300_000_000;
|
||||||
|
|
||||||
|
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
|
||||||
|
|
||||||
|
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
|
||||||
|
if (!c) return FULL_CROP;
|
||||||
|
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
|
||||||
|
// When letterboxing, the crop may be larger than the image (that is where the
|
||||||
|
// letterbox area appears); when cropping it has to lie inside the image.
|
||||||
|
const maxSide = fit === 'contain' ? 8 : 1;
|
||||||
|
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
|
||||||
|
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
|
||||||
|
if (fit === 'contain') {
|
||||||
|
// Only prevent the image from being pushed completely out of the crop.
|
||||||
|
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
|
||||||
|
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
const x = Math.min(1 - w, Math.max(0, num(c.x)));
|
||||||
|
const y = Math.min(1 - h, Math.max(0, num(c.y)));
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
|
||||||
|
|
||||||
|
/** Where the footer sits. The edge first, the alignment second.
|
||||||
|
* The values stay as they are — they cross the wire in the API request body. */
|
||||||
|
export type FooterPos =
|
||||||
|
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
||||||
|
| 'top-left' | 'top-center' | 'top-rechts';
|
||||||
|
|
||||||
|
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
|
||||||
|
|
||||||
|
/** Extras per page — for the poster print: sheet number and glue flap. */
|
||||||
|
export interface PageExtras { note?: string | null; dashed?: Line[] }
|
||||||
|
|
||||||
|
export interface SheetPdfInput {
|
||||||
|
sheet: SheetSpec;
|
||||||
|
pages: Page[];
|
||||||
|
marksPerPage: Line[][];
|
||||||
|
/** specId → finished rendered image (bleed included, rotated if applicable). */
|
||||||
|
images: Record<string, SheetCellImage>;
|
||||||
|
title?: string;
|
||||||
|
footer?: FooterSpec | null;
|
||||||
|
extrasPerPage?: PageExtras[];
|
||||||
|
markWidthPt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the print PDF. Page size = sheet size in mm, 1:1 — when printing, be
|
||||||
|
* sure to choose "Actual size / 100 %", not "fit to page".
|
||||||
|
*/
|
||||||
|
export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
||||||
|
const { sheet, pages, marksPerPage, images } = input;
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
pdf.setTitle(input.title || 'Klarbild print sheet');
|
||||||
|
pdf.setProducer('Klarbild');
|
||||||
|
pdf.setCreator('Klarbild — print sheet');
|
||||||
|
|
||||||
|
const embedded: Record<string, any> = {};
|
||||||
|
for (const [id, img] of Object.entries(images)) {
|
||||||
|
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
|
||||||
|
}
|
||||||
|
const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
|
||||||
|
const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null;
|
||||||
|
const bleed = sheet.bleedMm || 0;
|
||||||
|
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
|
||||||
|
|
||||||
|
pages.forEach((pg, i) => {
|
||||||
|
const page = pdf.addPage([W, H]);
|
||||||
|
for (const p of pg.placements) {
|
||||||
|
const im = embedded[p.specId];
|
||||||
|
if (!im) continue;
|
||||||
|
// Image including bleed: reaches beyond the trim box by `bleed` per side.
|
||||||
|
const x = mmToPt(p.x - bleed);
|
||||||
|
const yTop = p.y - bleed; // counted from the top
|
||||||
|
const w = mmToPt(p.w + 2 * bleed);
|
||||||
|
const h = mmToPt(p.h + 2 * bleed);
|
||||||
|
page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h });
|
||||||
|
}
|
||||||
|
for (const l of marksPerPage[i] || []) {
|
||||||
|
page.drawLine({
|
||||||
|
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||||
|
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||||
|
thickness: input.markWidthPt ?? 0.25,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Glue flaps in the poster print: dashed, so that nobody mistakes them for a
|
||||||
|
// cut edge.
|
||||||
|
const extras = input.extrasPerPage?.[i];
|
||||||
|
for (const l of extras?.dashed || []) {
|
||||||
|
page.drawLine({
|
||||||
|
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||||
|
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||||
|
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font) {
|
||||||
|
const put = (text: string, pos: FooterPos, size: number) => {
|
||||||
|
const w = font.widthOfTextAtSize(text, size);
|
||||||
|
const top = pos.startsWith('top');
|
||||||
|
const y = top ? H - mmToPt(3) - size : mmToPt(3);
|
||||||
|
const x = pos.endsWith('center') ? (W - w) / 2
|
||||||
|
: pos.endsWith('rechts') ? W - mmToPt(4) - w
|
||||||
|
: mmToPt(4);
|
||||||
|
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// The footer only if there really is room at the chosen edge —
|
||||||
|
// otherwise it would sit in the middle of the subject.
|
||||||
|
if (input.footer?.text) {
|
||||||
|
const pos = input.footer.position || 'bottom-left';
|
||||||
|
const size = input.footer.sizePt ?? 6;
|
||||||
|
const top = pos.startsWith('top');
|
||||||
|
const free = pg.placements.length
|
||||||
|
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
|
||||||
|
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
|
||||||
|
: sheet.hMm;
|
||||||
|
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
|
||||||
|
}
|
||||||
|
// The sheet number in a poster print is always there — it is the assembly instruction.
|
||||||
|
if (extras?.note) put(extras.note, 'top-left', 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdf.save();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Print history — what was printed last, and how to repeat it.
|
||||||
|
//
|
||||||
|
// The crucial point: what gets stored is the **instruction**, not the result.
|
||||||
|
// An A3 sheet as a PDF is easily 20–80 MB; a few dozen of those fill the disk
|
||||||
|
// with files that can be recalculated in seconds. So what lies here is the
|
||||||
|
// request body with which `/api/print/sheet` produces the same sheet again
|
||||||
|
// — around 1–3 KB per entry.
|
||||||
|
//
|
||||||
|
// That only works because the print module is **deterministic**: the same
|
||||||
|
// sources and the same settings give the same PDF. For the AI jobs it would be
|
||||||
|
// nonsense, there something different would come out every time.
|
||||||
|
import { one, query } from './db.ts';
|
||||||
|
|
||||||
|
export const MAX_RUNS_PER_USER = 100;
|
||||||
|
|
||||||
|
export interface PrintRun {
|
||||||
|
id: string;
|
||||||
|
created_by: string | null;
|
||||||
|
origin: string;
|
||||||
|
summary: string | null;
|
||||||
|
paper: string | null;
|
||||||
|
pages: number;
|
||||||
|
pieces: number;
|
||||||
|
bytes: number | null;
|
||||||
|
config: any;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunInput {
|
||||||
|
/** Predetermined id — the file name of the PDF carries its short id. */
|
||||||
|
id?: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
origin?: string;
|
||||||
|
summary: string;
|
||||||
|
paper: string;
|
||||||
|
pages: number;
|
||||||
|
pieces: number;
|
||||||
|
bytes: number;
|
||||||
|
/** The request body, unchanged. */
|
||||||
|
config: any;
|
||||||
|
/** Ids of the library images that were used. */
|
||||||
|
itemIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was on the sheet, in one sentence.
|
||||||
|
*
|
||||||
|
* The list comes in cell by cell — two cells with the same size would otherwise
|
||||||
|
* give "10 × 15 cm, 10 × 15 cm". Identical sizes are therefore pulled together
|
||||||
|
* and their piece counts added up; an existing "×3" is counted along, so that
|
||||||
|
* "×3 ×2" does not appear.
|
||||||
|
*/
|
||||||
|
export function summarise(kinds: string[]): string {
|
||||||
|
if (!kinds.length) return 'Print sheet';
|
||||||
|
|
||||||
|
// Only the **trailing** piece count counts: "35 × 45 mm ×8" has two × in it,
|
||||||
|
// and a greedy expression promptly turned "13 × 18 cm" into "13 cm ×18".
|
||||||
|
// That is why it is anchored to the end, with an optional addition in
|
||||||
|
// parentheses behind it.
|
||||||
|
const COUNT = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/;
|
||||||
|
|
||||||
|
const sums = new Map<string, number>();
|
||||||
|
for (const k of kinds) {
|
||||||
|
const m = COUNT.exec(k);
|
||||||
|
const base = m ? `${m[1]}${m[3] || ''}` : k;
|
||||||
|
const n = m ? Number(m[2]) : 1;
|
||||||
|
sums.set(base, (sums.get(base) || 0) + n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "30 × 40 cm (poster) ×3" would read awkwardly — the piece count belongs in
|
||||||
|
// front of the addition, where it came in.
|
||||||
|
const parts = [...sums].map(([base, n]) => {
|
||||||
|
if (n <= 1) return base;
|
||||||
|
const z = /^(.*?)(\s*\([^)]*\))$/.exec(base);
|
||||||
|
return z ? `${z[1]} ×${n}${z[2]}` : `${base} ×${n}`;
|
||||||
|
});
|
||||||
|
const s = parts.join(', ');
|
||||||
|
return s.length <= 120 ? s : `${s.slice(0, 117)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a print.
|
||||||
|
*
|
||||||
|
* An error here must **never** make the print fail — by that point the PDF has
|
||||||
|
* long been finished and is on its way to the user. That is why the caller
|
||||||
|
* catches, and this function only reports to the log.
|
||||||
|
*/
|
||||||
|
export async function recordRun(e: RunInput): Promise<string | null> {
|
||||||
|
const run = await one<{ id: string }>(
|
||||||
|
`INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config)
|
||||||
|
VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
|
[e.id || null, e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes,
|
||||||
|
JSON.stringify(e.config)]);
|
||||||
|
if (!run) return null;
|
||||||
|
|
||||||
|
const ids = [...new Set(e.itemIds.filter(Boolean))];
|
||||||
|
if (ids.length) {
|
||||||
|
// Images may have been deleted in the meantime — then the link falls away
|
||||||
|
// and the entry stays.
|
||||||
|
await query(
|
||||||
|
`INSERT INTO print_run_items (run_id, item_id)
|
||||||
|
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
||||||
|
WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
|
||||||
|
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
||||||
|
console.error('[printruns] image references not stored:', err?.message || err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear away older entries of the same user. Without this the list grows
|
||||||
|
// without bound, and nobody scrolls back to a print from two years ago.
|
||||||
|
if (e.userId) {
|
||||||
|
await query(
|
||||||
|
`DELETE FROM print_runs
|
||||||
|
WHERE created_by = $1 AND id NOT IN (
|
||||||
|
SELECT id FROM print_runs WHERE created_by = $1
|
||||||
|
ORDER BY created_at DESC LIMIT $2)`,
|
||||||
|
[e.userId, MAX_RUNS_PER_USER]).catch(() => {});
|
||||||
|
}
|
||||||
|
return run.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunRow extends PrintRun {
|
||||||
|
/** Images that still exist — only with those can the print be repeated. */
|
||||||
|
images: { id: string; filename: string | null }[];
|
||||||
|
/** How many **library images** have been deleted since. */
|
||||||
|
missing: number;
|
||||||
|
/** How many cells came from an uploaded file — those are gone. */
|
||||||
|
uploaded: number;
|
||||||
|
/** Can the sheet be produced again unchanged? */
|
||||||
|
repeatable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent prints. Admins see all of them, everybody else only their
|
||||||
|
* own — the same rule as for the share links.
|
||||||
|
*/
|
||||||
|
export async function listRuns(userId: string | null, isAdmin: boolean, limit = 30): Promise<RunRow[]> {
|
||||||
|
const runs = await query<PrintRun>(
|
||||||
|
`SELECT * FROM print_runs
|
||||||
|
WHERE $2::boolean OR created_by = $1
|
||||||
|
ORDER BY created_at DESC LIMIT $3`, [userId, isAdmin, Math.min(limit, 100)]);
|
||||||
|
if (!runs.length) return [];
|
||||||
|
|
||||||
|
const images = await query<{ run_id: string; id: string; filename: string | null }>(
|
||||||
|
`SELECT ri.run_id, i.id, i.filename
|
||||||
|
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
||||||
|
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
|
||||||
|
[runs.map((r) => r.id)]);
|
||||||
|
|
||||||
|
const byRun = new Map<string, { id: string; filename: string | null }[]>();
|
||||||
|
for (const b of images) {
|
||||||
|
const l = byRun.get(b.run_id) || [];
|
||||||
|
l.push({ id: b.id, filename: b.filename });
|
||||||
|
byRun.set(b.run_id, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs.map((r) => {
|
||||||
|
const present = byRun.get(r.id) || [];
|
||||||
|
const cells: any[] = Array.isArray(r.config?.cells) ? r.config.cells : [];
|
||||||
|
// Only cells from the library can be repeated. Uploaded files lie under
|
||||||
|
// `sources/…` and are cleaned up after processing depending on the settings
|
||||||
|
// — then the source is missing.
|
||||||
|
const fromLibrary = cells.filter((c) => c?.src?.kind === 'item').length;
|
||||||
|
// Different reasons, explained differently: a deleted library image is
|
||||||
|
// something other than a file that was never in the library. Throwing both
|
||||||
|
// into one counter led to "1 image is no longer there" for a sheet whose
|
||||||
|
// source was simply an upload.
|
||||||
|
const distinct = new Set(cells.filter((c) => c?.src?.kind === 'item')
|
||||||
|
.map((c) => String(c.src.id))).size;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
images: present,
|
||||||
|
missing: Math.max(0, distinct - present.length),
|
||||||
|
uploaded: cells.length - fromLibrary,
|
||||||
|
// Repeatable only if **all** cells come from the library and all images
|
||||||
|
// are still there. Half will not do: a sheet with a missing image would
|
||||||
|
// not be the same sheet.
|
||||||
|
repeatable: cells.length > 0 && fromLibrary === cells.length && present.length === cells.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a single print — for producing it again. */
|
||||||
|
export async function getRun(id: string, userId: string | null, isAdmin: boolean): Promise<PrintRun | null> {
|
||||||
|
return one<PrintRun>(
|
||||||
|
`SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`,
|
||||||
|
[id, userId, isAdmin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRun(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||||
|
const r = await query<{ id: string }>(
|
||||||
|
`DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`,
|
||||||
|
[id, userId, isAdmin]);
|
||||||
|
return r.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Resolving the image sources for the print sheet: freshly uploaded or from the library.
|
||||||
|
import { one } from './db';
|
||||||
|
import { getObject } from './storage';
|
||||||
|
|
||||||
|
export type PrintSource =
|
||||||
|
| { kind: 'upload'; path: string }
|
||||||
|
| { kind: 'item'; id: string };
|
||||||
|
|
||||||
|
export interface SessionUser { uid: string | null; role: string }
|
||||||
|
|
||||||
|
/** Loads the image data and checks the access rights while doing so, like /api/items/:id/file. */
|
||||||
|
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
|
||||||
|
return (await loadSourceNamed(src, user)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedSource {
|
||||||
|
buffer: Buffer;
|
||||||
|
/** Original file name, as far as it is known — for the name of the output file. */
|
||||||
|
filename: string | null;
|
||||||
|
/** Id of the library image, otherwise null — supplies the short id in the name. */
|
||||||
|
itemId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `loadSource`, but also returns the name of the source. */
|
||||||
|
export async function loadSourceNamed(src: PrintSource, user: SessionUser): Promise<LoadedSource> {
|
||||||
|
if (!src || typeof src !== 'object') throw new Error('Source missing.');
|
||||||
|
|
||||||
|
if (src.kind === 'upload') {
|
||||||
|
// Only the upload area can be reached — no route to results/ or /etc.
|
||||||
|
const p = String(src.path || '');
|
||||||
|
// \n deliberately excluded: JS "$" also matches before a trailing line break.
|
||||||
|
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
|
||||||
|
throw new Error('Invalid source.');
|
||||||
|
return { buffer: await getObject(p), filename: p.split('/').pop() || null, itemId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.kind === 'item') {
|
||||||
|
const item = await one<any>(
|
||||||
|
`SELECT i.result_path, i.filename, j.created_by, j.private
|
||||||
|
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
|
||||||
|
if (!item?.result_path) throw new Error('Image not found.');
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
const own = item.created_by === user.uid;
|
||||||
|
if (!isAdmin && !own) {
|
||||||
|
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||||
|
if (item.private || s?.library_visibility !== 'shared') throw new Error('No access to this image.');
|
||||||
|
}
|
||||||
|
return { buffer: await getObject(item.result_path), filename: item.filename ?? null, itemId: String(src.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown kind of source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// `sheetFilename` is gone: all it did was put the date in front of the name, so
|
||||||
|
// two sheets from the same day were called the same. The naming rules now live
|
||||||
|
// in full in `naming.ts` — the same ones as for library results.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { one, query } from './db';
|
||||||
|
import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
|
||||||
|
import { generateImage } from './openrouter';
|
||||||
|
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
|
||||||
|
import { resolveDimensions, aspectRatio, type Orientation } from './format';
|
||||||
|
import { finalizeToFormat, stickerContour, type CropMode } from './pipeline';
|
||||||
|
import { formatToken } from './naming';
|
||||||
|
import { uniqueFilename } from './renames';
|
||||||
|
import { embedProvenance, TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA } from './provenance';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
||||||
|
|
||||||
|
type Mode = 'each' | 'compose' | 'generate';
|
||||||
|
|
||||||
|
interface RecipeSnapshot {
|
||||||
|
tasks: Task[];
|
||||||
|
output_format?: string;
|
||||||
|
orientation?: Orientation;
|
||||||
|
crop_mode?: CropMode;
|
||||||
|
dpi?: number;
|
||||||
|
contour_mm?: number | null;
|
||||||
|
model_key?: string | null;
|
||||||
|
custom_instruction?: string | null;
|
||||||
|
prompt_text?: string | null; // description for compose/generate
|
||||||
|
delivery?: 'library' | 'remote' | 'both';
|
||||||
|
delivery_folder?: string | null;
|
||||||
|
output_ext?: 'png' | 'jpg' | null; // file-format override; otherwise the global setting
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which model id + alpha capability? From the models table, otherwise the default. */
|
||||||
|
async function pickModel(modelKey?: string | null): Promise<{ id: string; alpha: boolean }> {
|
||||||
|
if (modelKey) {
|
||||||
|
const m = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||||
|
'SELECT model_id, supports_alpha FROM models WHERE label=$1 OR model_id=$1 LIMIT 1', [modelKey]);
|
||||||
|
if (m) return { id: m.model_id, alpha: m.supports_alpha };
|
||||||
|
}
|
||||||
|
const def = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||||
|
'SELECT model_id, supports_alpha FROM models WHERE active AND is_default ORDER BY sort LIMIT 1');
|
||||||
|
if (def) return { id: def.model_id, alpha: def.supports_alpha };
|
||||||
|
return { id: DEFAULT_MODEL, alpha: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toDataUrl(buf: Buffer): Promise<string> {
|
||||||
|
const small = await sharp(buf, { failOn: 'none' })
|
||||||
|
.resize(1536, 1536, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 90 })
|
||||||
|
.toBuffer();
|
||||||
|
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small preview image (webp) for the library — saves bandwidth and storage. */
|
||||||
|
async function makeThumb(buf: Buffer): Promise<Buffer> {
|
||||||
|
return sharp(buf, { failOn: 'none' })
|
||||||
|
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 72 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
||||||
|
|
||||||
|
/** Processes one item completely and updates its record. */
|
||||||
|
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||||
|
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
|
||||||
|
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
||||||
|
if (!item) return { ok: false, cost: 0, error: 'Item not found' };
|
||||||
|
|
||||||
|
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||||
|
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
|
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||||
|
const mode: Mode = job?.mode || 'each';
|
||||||
|
const isPrivate = !!job?.private;
|
||||||
|
const tasks = r.tasks || [];
|
||||||
|
const dpi = r.dpi ?? 300;
|
||||||
|
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
||||||
|
const wantCutout = tasks.includes('cutout');
|
||||||
|
const wantContour = tasks.includes('contour');
|
||||||
|
const description = (r.prompt_text || '').trim();
|
||||||
|
|
||||||
|
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Collect the sources per mode
|
||||||
|
const sourceKeys: string[] =
|
||||||
|
mode === 'compose' ? (item.source_paths || []).filter(Boolean)
|
||||||
|
: mode === 'generate' ? []
|
||||||
|
: (item.source_path ? [item.source_path] : []);
|
||||||
|
// Does this mode need a source but there is none (any more)? A clear message instead of a raw fs error.
|
||||||
|
if (mode !== 'generate' && sourceKeys.length === 0) {
|
||||||
|
const e: any = new Error('Source no longer available — please upload the image again.');
|
||||||
|
e.friendly = 'Source no longer available — please upload the image again.';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const sources = await Promise.all(sourceKeys.map(async (k) => {
|
||||||
|
try { return await getObject(k); }
|
||||||
|
catch {
|
||||||
|
const e: any = new Error('Source file no longer available — please upload the image again.');
|
||||||
|
e.friendly = 'Source file no longer available — please upload the image again.';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const target = tasks.includes('format') && r.output_format
|
||||||
|
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const model = await pickModel(r.model_key);
|
||||||
|
|
||||||
|
// The prompt + whether the model is needed at all, per mode.
|
||||||
|
let prompt: string;
|
||||||
|
let needsModel: boolean;
|
||||||
|
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
|
||||||
|
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
|
||||||
|
else {
|
||||||
|
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
|
||||||
|
needsModel = tasks.includes('clean') || wantCutout ||
|
||||||
|
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
|
||||||
|
}
|
||||||
|
|
||||||
|
let working = sources[0] || Buffer.alloc(0);
|
||||||
|
let modelUsed: string | null = null;
|
||||||
|
let cost = 0;
|
||||||
|
|
||||||
|
if (needsModel) {
|
||||||
|
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
|
||||||
|
const gen = await generateImage({
|
||||||
|
model: model.id,
|
||||||
|
prompt,
|
||||||
|
inputUrls,
|
||||||
|
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
||||||
|
background: wantCutout ? 'transparent' : undefined,
|
||||||
|
outputFormat: 'png',
|
||||||
|
});
|
||||||
|
working = gen.buffer;
|
||||||
|
modelUsed = gen.model;
|
||||||
|
cost = gen.cost;
|
||||||
|
}
|
||||||
|
if (!working.length) throw new Error('No image data produced');
|
||||||
|
|
||||||
|
// Local post-processing: exactly onto the target size + dpi
|
||||||
|
const fin = await finalizeToFormat(working, target, cropMode, dpi);
|
||||||
|
let outBuf = fin.buffer;
|
||||||
|
let hasAlpha = fin.hasAlpha || wantCutout;
|
||||||
|
|
||||||
|
if (wantContour && r.contour_mm) {
|
||||||
|
outBuf = await stickerContour(outBuf, Number(r.contour_mm), dpi);
|
||||||
|
hasAlpha = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output format: the preset recipe's override, otherwise the global setting. JPG only without transparency.
|
||||||
|
const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1');
|
||||||
|
const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase();
|
||||||
|
let ext = 'png';
|
||||||
|
let contentType = 'image/png';
|
||||||
|
if (wantExt === 'jpg' && !hasAlpha) {
|
||||||
|
outBuf = await sharp(outBuf, { failOn: 'none' })
|
||||||
|
.flatten({ background: '#ffffff' })
|
||||||
|
.withMetadata({ density: dpi })
|
||||||
|
.jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
||||||
|
.toBuffer();
|
||||||
|
ext = 'jpg';
|
||||||
|
contentType = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the AI provenance into the file in machine-readable form (IPTC DigitalSourceType).
|
||||||
|
// "generate" is fully generated, everything else is an altered photo —
|
||||||
|
// the IPTC list has a code of its own for that.
|
||||||
|
outBuf = embedProvenance(outBuf, {
|
||||||
|
sourceType: mode === 'generate' ? TRAINED_ALGORITHMIC_MEDIA : COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
||||||
|
model: modelUsed,
|
||||||
|
description: isPrivate ? null : prompt.slice(0, 300),
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = resultKey(item.id);
|
||||||
|
await putObject(key, outBuf, contentType);
|
||||||
|
|
||||||
|
// Preview image (optional, per the setting)
|
||||||
|
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; mirror_enabled: boolean }>(
|
||||||
|
'SELECT make_thumbnails, keep_sources, mirror_enabled FROM settings WHERE id=1');
|
||||||
|
let thumbPath: string | null = null;
|
||||||
|
if (cfg?.make_thumbnails !== false) {
|
||||||
|
try {
|
||||||
|
const tk = thumbKey(item.id);
|
||||||
|
await putObject(tk, await makeThumb(outBuf), 'image/webp');
|
||||||
|
thumbPath = tk;
|
||||||
|
} catch (e) { console.error('[process] thumbnail failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique per image: the second plus the item's short id in the name, plus a
|
||||||
|
// cross-check in the database. Before this, two similar images from the same
|
||||||
|
// day could get the same name and overwrite each other on the delivery target.
|
||||||
|
const filename = await uniqueFilename(itemId, item.filename, formatToken(r.output_format), ext);
|
||||||
|
const outputPx = `${fin.width}x${fin.height}`;
|
||||||
|
// Private jobs: no delivery, no backup, no stored prompt.
|
||||||
|
const deliveryStatus = (!isPrivate && (r.delivery === 'remote' || r.delivery === 'both')) ? 'pending' : 'none';
|
||||||
|
const mirrorStatus = (!isPrivate && cfg?.mirror_enabled) ? 'pending' : 'none';
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
||||||
|
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
||||||
|
delivery_status=$11, mirror_status=$12 WHERE id=$1`,
|
||||||
|
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed,
|
||||||
|
isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, mirrorStatus]);
|
||||||
|
|
||||||
|
// Delete the source images unless they are kept — for private jobs always.
|
||||||
|
if (isPrivate || cfg?.keep_sources === false) {
|
||||||
|
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
||||||
|
if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second copy on every backup target — not for private jobs.
|
||||||
|
if (!isPrivate) {
|
||||||
|
try {
|
||||||
|
const { backupItem } = await import('./backup');
|
||||||
|
await backupItem(itemId);
|
||||||
|
} catch (e) { console.error('[process] backup failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, cost };
|
||||||
|
} catch (e: any) {
|
||||||
|
const real = e?.message || String(e);
|
||||||
|
const status = e?.status ? ` [status ${e.status}]` : '';
|
||||||
|
console.error(`[process] item ${itemId} failed${status}: ${real}`, e?.stack || '');
|
||||||
|
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
|
||||||
|
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
||||||
|
[itemId, String(msg).slice(0, 500)]);
|
||||||
|
if (e?.status === 402) throw e;
|
||||||
|
return { ok: false, cost: 0, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Task prompts for the image API. Guiding principle (01 §1): reproduce and extend,
|
||||||
|
// do not reinterpret. Leave colours, composition, textures and above all the
|
||||||
|
// lettering faithful.
|
||||||
|
//
|
||||||
|
// The prompt text stays in English on purpose: it is a functional instruction to
|
||||||
|
// the image model, not something a person reads, so it is not translated.
|
||||||
|
|
||||||
|
export type Task = 'clean' | 'cutout' | 'format' | 'contour' | 'deliver';
|
||||||
|
|
||||||
|
const CLEAN =
|
||||||
|
'Recreate ONLY the actual artwork/motif as a clean, complete, high-resolution image. ' +
|
||||||
|
'Remove everything around it that is not part of the motif: picture frames, walls, ' +
|
||||||
|
'phone/app UI bars, shop overlays, cart icons, price tags, watermarks from your own ' +
|
||||||
|
'screenshot, timestamps and reflections. Reconstruct edges that were cropped or hidden. ' +
|
||||||
|
'Keep the original style, colors, textures, composition and ESPECIALLY any lettering ' +
|
||||||
|
'pixel-faithful. Do not reinterpret, do not add new elements.';
|
||||||
|
|
||||||
|
const CUTOUT =
|
||||||
|
'Isolate only the main subject. The background must be fully transparent (alpha).';
|
||||||
|
|
||||||
|
const FORMAT_EXTEND =
|
||||||
|
'Extend the scene naturally to fill the requested aspect ratio (outpainting), keeping ' +
|
||||||
|
'the existing composition centered and consistent in style.';
|
||||||
|
|
||||||
|
const FORMAT_KEEP_RATIO =
|
||||||
|
'Produce the motif in the requested aspect ratio without distorting it.';
|
||||||
|
|
||||||
|
export interface PromptOpts {
|
||||||
|
tasks: Task[];
|
||||||
|
cropMode?: 'crop' | 'extend';
|
||||||
|
customInstruction?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the combined prompt out of the preset recipe's tasks (in a fixed order). */
|
||||||
|
export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (tasks.includes('clean')) parts.push(CLEAN);
|
||||||
|
if (tasks.includes('cutout')) parts.push(CUTOUT);
|
||||||
|
if (tasks.includes('format')) {
|
||||||
|
parts.push(cropMode === 'extend' ? FORMAT_EXTEND : FORMAT_KEEP_RATIO);
|
||||||
|
}
|
||||||
|
if (!tasks.includes('clean') && !tasks.includes('cutout') && parts.length === 0) {
|
||||||
|
// format only, without cleaning up: just reproduce
|
||||||
|
parts.push('Reproduce the image faithfully, no stylistic changes.');
|
||||||
|
}
|
||||||
|
if (customInstruction?.trim()) parts.push(`Additional instruction: ${customInstruction.trim()}`);
|
||||||
|
parts.push('Output only the resulting image.');
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine/convert: one OR several reference images + a description → ONE new image.
|
||||||
|
* With one reference image = convert the image per the instruction (a photo → an oil painting, say).
|
||||||
|
* With several = the first one leads, the others supply elements/people/subjects. */
|
||||||
|
export function buildComposePrompt(description: string, extend = false, count = 2): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (count <= 1) {
|
||||||
|
parts.push(
|
||||||
|
'Transform the given image according to the instruction below into ONE new, ' +
|
||||||
|
'high-resolution image. Keep the subject, composition and important details recognizable ' +
|
||||||
|
'unless the instruction explicitly asks to change them.');
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
'You are given several reference images. Combine them into ONE new, coherent, ' +
|
||||||
|
'high-resolution image that follows the instruction below. Treat the first image as ' +
|
||||||
|
'the main scene/style reference and use the other images as elements to integrate ' +
|
||||||
|
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
|
||||||
|
'they look naturally part of the same photo.');
|
||||||
|
}
|
||||||
|
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
|
||||||
|
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
|
||||||
|
parts.push('Output only the resulting image.');
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free text: no reference image at all → a completely new image out of the description. */
|
||||||
|
export function buildGeneratePrompt(description: string): string {
|
||||||
|
const d = description.trim() || 'A clean, high-quality image.';
|
||||||
|
return `Create a new, high-resolution image. ${d} Output only the resulting image.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Machine-readable provenance marking for AI-generated images.
|
||||||
|
//
|
||||||
|
// Why: the EU AI Act (Art. 50) requires AI-generated content to be marked as
|
||||||
|
// such **in machine-readable form**. The `.md` sidecar file that Klarbild ships
|
||||||
|
// along is good documentation for people — but it is not a marking in the sense
|
||||||
|
// of the regulation. The established route is the IPTC field `DigitalSourceType`
|
||||||
|
// with the value `trainedAlgorithmicMedia`, embedded as XMP directly in the file.
|
||||||
|
// (This does not replace legal advice; it implements what is standard and common
|
||||||
|
// practice.)
|
||||||
|
//
|
||||||
|
// Deliberately **without a new dependency**: sharp 0.33 cannot write XMP, and
|
||||||
|
// upgrading the native library just for that would be a deployment risk (see the
|
||||||
|
// learnings about `tsx`). Hooking XMP into PNG and JPEG is cleanly specified in
|
||||||
|
// both cases and done in a few lines — and therefore testable as well.
|
||||||
|
|
||||||
|
// The identifier ends with a null byte, as the standard requires. Written as an
|
||||||
|
// escape: as a raw control character in the source file a formatter or an editor
|
||||||
|
// would turn it into a space — and then no tool would recognise the segment as
|
||||||
|
// XMP any more, and no test would catch it.
|
||||||
|
const XMP_NS = 'http://ns.adobe.com/xap/1.0/\u0000';
|
||||||
|
const BOM = '';
|
||||||
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
/** The IPTC code for "created by a trained algorithm". */
|
||||||
|
export const TRAINED_ALGORITHMIC_MEDIA =
|
||||||
|
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia';
|
||||||
|
|
||||||
|
/** For images that a model only *changed* (clean up, cut out). */
|
||||||
|
export const COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA =
|
||||||
|
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia';
|
||||||
|
|
||||||
|
export interface Provenance {
|
||||||
|
/** IPTC code, see the constants above. */
|
||||||
|
sourceType: string;
|
||||||
|
/** Model used, e.g. `google/gemini-3-pro-image`. */
|
||||||
|
model?: string | null;
|
||||||
|
/** Short description of what was done. */
|
||||||
|
description?: string | null;
|
||||||
|
/** Time of creation. */
|
||||||
|
created?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s: string) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/** The XMP packet as text — what is about to go into the file. */
|
||||||
|
export function buildXmp(p: Provenance): string {
|
||||||
|
const created = (p.created ?? new Date()).toISOString();
|
||||||
|
const tool = p.model ? `Klarbild (${p.model})` : 'Klarbild';
|
||||||
|
const desc = p.description
|
||||||
|
? `\n <dc:description><rdf:Alt><rdf:li xml:lang="x-default">${esc(p.description)}</rdf:li></rdf:Alt></dc:description>`
|
||||||
|
: '';
|
||||||
|
return `<?xpacket begin="${BOM}" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Klarbild">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
|
||||||
|
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
Iptc4xmpExt:DigitalSourceType="${esc(p.sourceType)}"
|
||||||
|
xmp:CreatorTool="${esc(tool)}"
|
||||||
|
xmp:CreateDate="${created}">${desc}
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- PNG ---------------- */
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const t = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
t[n] = c;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf: Buffer): number {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a PNG chunk: length · type · data · CRC over type+data. */
|
||||||
|
function pngChunk(type: string, data: Buffer): Buffer {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
|
||||||
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
|
||||||
|
return Buffer.concat([len, body, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hang the XMP in as an `iTXt` chunk behind the IHDR chunk. The PNG
|
||||||
|
* specification requires text chunks to stand before the first `IDAT` — directly
|
||||||
|
* behind IHDR is the safe spot.
|
||||||
|
*/
|
||||||
|
function embedPng(buf: Buffer, xmp: string): Buffer {
|
||||||
|
// 8 bytes of signature, then IHDR: 4 length + 4 type + data + 4 CRC
|
||||||
|
const ihdrLen = buf.readUInt32BE(8);
|
||||||
|
const insertAt = 8 + 4 + 4 + ihdrLen + 4;
|
||||||
|
const data = Buffer.concat([
|
||||||
|
Buffer.from('XML:com.adobe.xmp', 'latin1'),
|
||||||
|
// null byte · compression 0 · method 0 · language "" · translated keyword ""
|
||||||
|
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||||
|
Buffer.from(xmp, 'utf8'),
|
||||||
|
]);
|
||||||
|
return Buffer.concat([buf.subarray(0, insertAt), pngChunk('iTXt', data), buf.subarray(insertAt)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- JPEG ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the XMP in as an `APP1` segment. An existing APP0 (JFIF) and an existing
|
||||||
|
* APP1 (Exif, written by sharp for the dpi) stay in front of it — the two are
|
||||||
|
* told apart by their identifier, they do not get in each other's way.
|
||||||
|
*/
|
||||||
|
function embedJpeg(buf: Buffer, xmp: string): Buffer {
|
||||||
|
let pos = 2; // behind SOI (FFD8)
|
||||||
|
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
|
||||||
|
const marker = buf[pos + 1];
|
||||||
|
if (marker !== 0xe0 && marker !== 0xe1) break; // only skip APP0/APP1
|
||||||
|
pos += 2 + buf.readUInt16BE(pos + 2);
|
||||||
|
}
|
||||||
|
const payload = Buffer.concat([Buffer.from(XMP_NS, 'latin1'), Buffer.from(xmp, 'utf8')]);
|
||||||
|
const len = payload.length + 2;
|
||||||
|
if (len > 0xffff) return buf; // does not fit into one segment — then rather nothing at all
|
||||||
|
const head = Buffer.alloc(4);
|
||||||
|
head.writeUInt16BE(0xffe1, 0);
|
||||||
|
head.writeUInt16BE(len, 2);
|
||||||
|
return Buffer.concat([buf.subarray(0, pos), head, payload, buf.subarray(pos)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the marking into the image file. Recognises PNG and JPEG by itself.
|
||||||
|
* For anything else (or on an error) the buffer comes back unchanged — a missing
|
||||||
|
* marking is annoying, a broken image would be worse.
|
||||||
|
*/
|
||||||
|
export function embedProvenance(buf: Buffer, p: Provenance): Buffer {
|
||||||
|
try {
|
||||||
|
// Remove a marking that is already there instead of appending a second one:
|
||||||
|
// otherwise the **older** model entry would stand in front, and reading
|
||||||
|
// tools would take exactly that one. For a marking under Art. 50 AI Act,
|
||||||
|
// though, the model actually used last is what should stand there.
|
||||||
|
const clean = stripXmp(buf);
|
||||||
|
const xmp = buildXmp(p);
|
||||||
|
if (clean.subarray(0, 8).equals(PNG_MAGIC)) return embedPng(clean, xmp);
|
||||||
|
if (clean[0] === 0xff && clean[1] === 0xd8) return embedJpeg(clean, xmp);
|
||||||
|
return clean;
|
||||||
|
} catch {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove existing XMP packets (PNG iTXt or JPEG APP1). */
|
||||||
|
export function stripXmp(buf: Buffer): Buffer {
|
||||||
|
if (buf.subarray(0, 8).equals(PNG_MAGIC)) {
|
||||||
|
const parts: Buffer[] = [buf.subarray(0, 8)];
|
||||||
|
let pos = 8;
|
||||||
|
while (pos + 12 <= buf.length) {
|
||||||
|
const len = buf.readUInt32BE(pos);
|
||||||
|
const type = buf.toString('latin1', pos + 4, pos + 8);
|
||||||
|
const end = pos + 12 + len;
|
||||||
|
if (end > buf.length) break;
|
||||||
|
const isXmp = type === 'iTXt'
|
||||||
|
&& buf.toString('latin1', pos + 8, pos + 8 + 17) === 'XML:com.adobe.xmp';
|
||||||
|
if (!isXmp) parts.push(buf.subarray(pos, end));
|
||||||
|
pos = end;
|
||||||
|
if (type === 'IEND') break;
|
||||||
|
}
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
||||||
|
const parts: Buffer[] = [buf.subarray(0, 2)];
|
||||||
|
let pos = 2;
|
||||||
|
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
|
||||||
|
const marker = buf[pos + 1];
|
||||||
|
if (marker === 0xda) break; // from here on come the image data
|
||||||
|
const len = buf.readUInt16BE(pos + 2);
|
||||||
|
const end = pos + 2 + len;
|
||||||
|
if (end > buf.length) break;
|
||||||
|
const isXmp = marker === 0xe1
|
||||||
|
&& buf.toString('latin1', pos + 4, pos + 4 + XMP_NS.length) === XMP_NS;
|
||||||
|
if (!isXmp) parts.push(buf.subarray(pos, end));
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
parts.push(buf.subarray(pos));
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the marking back out — for tests and for checking. */
|
||||||
|
export function readXmp(buf: Buffer): string | null {
|
||||||
|
const i = buf.indexOf('<x:xmpmeta');
|
||||||
|
if (i < 0) return null;
|
||||||
|
const j = buf.indexOf('</x:xmpmeta>', i);
|
||||||
|
return j < 0 ? null : buf.subarray(i, j + 12).toString('utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import PgBoss from 'pg-boss';
|
||||||
|
|
||||||
|
export const QUEUE = 'generate';
|
||||||
|
export interface GenerateJob { itemId: string; jobId: string; }
|
||||||
|
|
||||||
|
let boss: PgBoss | null = null;
|
||||||
|
|
||||||
|
export async function getBoss(): Promise<PgBoss> {
|
||||||
|
if (!boss) {
|
||||||
|
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||||
|
boss.on('error', (e) => console.error('[pg-boss]', e));
|
||||||
|
await boss.start();
|
||||||
|
await boss.createQueue(QUEUE);
|
||||||
|
}
|
||||||
|
return boss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueue(job: GenerateJob): Promise<void> {
|
||||||
|
const b = await getBoss();
|
||||||
|
await b.send(QUEUE, job, { retryLimit: 2, retryBackoff: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWorker(
|
||||||
|
concurrency: number,
|
||||||
|
handler: (job: GenerateJob) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const b = await getBoss();
|
||||||
|
await b.work<GenerateJob>(
|
||||||
|
QUEUE,
|
||||||
|
{ batchSize: Math.max(1, concurrency), pollingIntervalSeconds: 2 },
|
||||||
|
async (jobs) => { await Promise.all(jobs.map((j) => handler(j.data))); },
|
||||||
|
);
|
||||||
|
console.log(`[queue] Worker active (concurrency ${concurrency}).`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Delivery to a remote target via SFTP (ssh2-sftp-client) or FTPS (basic-ftp).
|
||||||
|
// The configuration comes from settings (with an encrypted password).
|
||||||
|
import posixpath from 'node:path/posix';
|
||||||
|
import { one } from './db.ts';
|
||||||
|
import { decrypt } from './crypto.ts';
|
||||||
|
|
||||||
|
export interface RemoteTargetCfg {
|
||||||
|
host: string; protocol: 'ftps' | 'sftp'; port: number;
|
||||||
|
user: string; password: string; basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(): Promise<RemoteTargetCfg | null> {
|
||||||
|
const s = await one<any>(`SELECT delivery_host, delivery_protocol, delivery_port, delivery_user,
|
||||||
|
delivery_password_enc, delivery_base_path FROM settings WHERE id=1`);
|
||||||
|
if (!s?.delivery_host || !s?.delivery_user || !s?.delivery_password_enc) return null;
|
||||||
|
let password = '';
|
||||||
|
try { password = decrypt(s.delivery_password_enc); } catch { return null; }
|
||||||
|
return {
|
||||||
|
host: s.delivery_host, protocol: (s.delivery_protocol || 'sftp'),
|
||||||
|
port: s.delivery_port || (s.delivery_protocol === 'ftps' ? 21 : 22),
|
||||||
|
user: s.delivery_user, password, basePath: s.delivery_base_path || '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An additional delivery target from delivery_targets. */
|
||||||
|
export async function loadTargetConfig(id: string): Promise<RemoteTargetCfg | null> {
|
||||||
|
const s = await one<any>(`SELECT protocol, host, port, username, password_enc, base_path
|
||||||
|
FROM delivery_targets WHERE id=$1`, [id]);
|
||||||
|
if (!s?.host || !s?.username || !s?.password_enc) return null;
|
||||||
|
let password = '';
|
||||||
|
try { password = decrypt(s.password_enc); } catch { return null; }
|
||||||
|
return {
|
||||||
|
host: s.host, protocol: (s.protocol || 'sftp'),
|
||||||
|
port: s.port || (s.protocol === 'ftps' ? 21 : 22),
|
||||||
|
user: s.username, password, basePath: s.base_path || '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test the connection: connect + list the base folder. */
|
||||||
|
export async function testConnection(cfg: RemoteTargetCfg): Promise<{ ok: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||||
|
await c.list(cfg.basePath || '/');
|
||||||
|
await c.end();
|
||||||
|
} else {
|
||||||
|
const { Client } = await import('basic-ftp');
|
||||||
|
const c = new Client(15000);
|
||||||
|
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||||
|
await c.list(cfg.basePath || '/');
|
||||||
|
c.close();
|
||||||
|
}
|
||||||
|
return { ok: true, message: 'Connection succeeded.' };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, message: e?.message || 'Connection failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A path segment that can be appended to the base folder without danger.
|
||||||
|
*
|
||||||
|
* `posixpath.join` is **no** protection: `join('/customers/acme', '../../../etc/cron.d')`
|
||||||
|
* yields `/etc/cron.d`, and the upload creates the directory beforehand. Folder
|
||||||
|
* names and file names come from the database (preset recipes, folders,
|
||||||
|
* settings) and therefore ultimately from user input — so they are checked
|
||||||
|
* **immediately before writing**, not somewhere further up. Nobody who delivers
|
||||||
|
* can get around this point.
|
||||||
|
*/
|
||||||
|
export function safeSegment(value: string, what: 'folder' | 'file name'): string {
|
||||||
|
const v = String(value || '').trim();
|
||||||
|
if (!v) return '';
|
||||||
|
// Deliberately a block list of the *dangerous* constructs rather than a narrow
|
||||||
|
// allow list: a customer folder is allowed to be called "Søren & Co. (2026)" —
|
||||||
|
// that is harmless. Only separators, control characters and names made purely
|
||||||
|
// of dots are dangerous, because those alone are enough to leave the base folder.
|
||||||
|
if (/[\/\\]/.test(v)) throw new Error(`Invalid ${what}: no slashes allowed.`);
|
||||||
|
if (/[\u0000-\u001f\u007f]/.test(v)) throw new Error(`Invalid ${what}: control characters.`);
|
||||||
|
if (/^\.+$/.test(v)) throw new Error(`Invalid ${what}: "." and ".." are not names.`);
|
||||||
|
if (v.length > 128) throw new Error(`Invalid ${what}: too long.`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploads a buffer straight under its final name.
|
||||||
|
* (The delivery target does not cope with the temp-then-rename pattern — `.tmp-…`
|
||||||
|
* files were left lying around.) */
|
||||||
|
export async function uploadBuffer(cfg: RemoteTargetCfg, folder: string, filename: string, buf: Buffer): Promise<void> {
|
||||||
|
const dir = posixpath.join(cfg.basePath || '/', safeSegment(folder, 'folder'));
|
||||||
|
const finalPath = posixpath.join(dir, safeSegment(filename, 'file name'));
|
||||||
|
const { Readable } = await import('node:stream');
|
||||||
|
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||||
|
try {
|
||||||
|
if (!(await c.exists(dir))) await c.mkdir(dir, true);
|
||||||
|
await c.put(buf, finalPath);
|
||||||
|
} finally { await c.end(); }
|
||||||
|
} else {
|
||||||
|
const { Client } = await import('basic-ftp');
|
||||||
|
const c = new Client(20000);
|
||||||
|
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||||
|
try {
|
||||||
|
await c.ensureDir(dir);
|
||||||
|
await c.uploadFrom(Readable.from(buf), finalPath);
|
||||||
|
} finally { c.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes `.tmp-…` leftovers from a folder (a one-off cleanup helper). */
|
||||||
|
export async function cleanupTmp(cfg: RemoteTargetCfg, folder: string): Promise<number> {
|
||||||
|
const dir = posixpath.join(cfg.basePath || '/', folder || '');
|
||||||
|
let n = 0;
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||||
|
try {
|
||||||
|
const list = await c.list(dir);
|
||||||
|
for (const e of list) if (e.name.startsWith('.tmp-')) { await c.delete(posixpath.join(dir, e.name)); n++; }
|
||||||
|
} finally { await c.end(); }
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Unique file names — assigning them for new images and renaming the library.
|
||||||
|
//
|
||||||
|
// Background: until 2026-08-20 a result was called `YYYY-MM-DD_<motif>_<format>.<ext>`.
|
||||||
|
// Whoever produced the same motif in the same format twice on the same day — two
|
||||||
|
// badges from one shoot, say — got the same name twice. On the delivery target
|
||||||
|
// the second image overwrote the first. Since then the name also carries the
|
||||||
|
// time of day to the second and a short id derived from the item id.
|
||||||
|
import { query, one } from './db.ts';
|
||||||
|
import { buildResultFilename, newNameFor } from './naming.ts';
|
||||||
|
|
||||||
|
/** Is this name already taken by a *different* image? */
|
||||||
|
async function taken(name: string, exceptId: string): Promise<boolean> {
|
||||||
|
const row = await one<{ id: string }>(
|
||||||
|
'SELECT id FROM items WHERE filename = $1 AND id <> $2 LIMIT 1', [name, exceptId]);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name is practically unique already through second + item short id. This
|
||||||
|
* loop is the belt to go with the braces: it appends `-2`, `-3` … in case the
|
||||||
|
* name is taken against all expectation. Deliberately as a loop in code and
|
||||||
|
* **not** as a UNIQUE constraint in the database — a rejected write would make
|
||||||
|
* an already paid-for AI job fail at the very last step.
|
||||||
|
*/
|
||||||
|
export async function uniqueFilename(
|
||||||
|
itemId: string, originalName: string | null, formatToken: string, ext: string, at?: Date,
|
||||||
|
/** For tests only: otherwise the library is asked. */
|
||||||
|
isTaken: (name: string) => Promise<boolean> = (n) => taken(n, itemId),
|
||||||
|
): Promise<string> {
|
||||||
|
for (let seq = 1; seq <= 50; seq++) {
|
||||||
|
const name = buildResultFilename(originalName, formatToken, ext, { at, id: itemId, seq });
|
||||||
|
if (!(await isTaken(name))) return name;
|
||||||
|
}
|
||||||
|
// Unreachable as long as item ids are unique — but an ugly name is preferable
|
||||||
|
// to an overwritten image.
|
||||||
|
return buildResultFilename(originalName, formatToken, ext, { at, id: `${itemId}${Date.now()}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameRow { id: string; before: string; after: string }
|
||||||
|
export interface RenameResult {
|
||||||
|
checked: number;
|
||||||
|
renamed: number;
|
||||||
|
unchanged: number;
|
||||||
|
/** Names that were assigned twice before — exactly the reported bug. */
|
||||||
|
wereDuplicates: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
examples: RenameRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring all library images onto the new naming scheme.
|
||||||
|
* Idempotent: the timestamp comes from `created_at`, the short id from the item
|
||||||
|
* id — a second run changes nothing any more. The files themselves stay
|
||||||
|
* untouched, they lie in storage under the item id, not under the name.
|
||||||
|
*/
|
||||||
|
export async function renameLibrary(dryRun = true): Promise<RenameResult> {
|
||||||
|
const items = await query<{ id: string; filename: string | null; created_at: Date }>(
|
||||||
|
`SELECT id, filename, created_at FROM items
|
||||||
|
WHERE status='done' AND filename IS NOT NULL ORDER BY created_at`);
|
||||||
|
|
||||||
|
// How many names were assigned more than once before?
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const it of items) counts.set(it.filename!, (counts.get(it.filename!) || 0) + 1);
|
||||||
|
const wereDuplicates = [...counts.values()].filter((n) => n > 1).reduce((a, n) => a + n - 1, 0);
|
||||||
|
|
||||||
|
const used = new Set<string>();
|
||||||
|
const rows: RenameRow[] = [];
|
||||||
|
let renamed = 0, unchanged = 0;
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
let next = newNameFor(it);
|
||||||
|
for (let seq = 2; used.has(next) && seq <= 50; seq++) next = newNameFor(it, seq);
|
||||||
|
used.add(next);
|
||||||
|
if (next === it.filename) { unchanged++; continue; }
|
||||||
|
renamed++;
|
||||||
|
if (rows.length < 25) rows.push({ id: it.id, before: it.filename!, after: next });
|
||||||
|
if (!dryRun) await query('UPDATE items SET filename=$2 WHERE id=$1', [it.id, next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked: items.length, renamed, unchanged, wereDuplicates, dryRun: dryRun, examples: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver all images that were already delivered once again — under the new
|
||||||
|
* name. Necessary because the renaming only affects the library: whatever
|
||||||
|
* already lies on the delivery target is still called by its old name there. The
|
||||||
|
* old files stay put and have to be removed by hand if wanted; deleting
|
||||||
|
* something at the customer's end automatically would be the wrong reflex.
|
||||||
|
*/
|
||||||
|
export async function redeliverAll(): Promise<{ attempted: number; ok: number; failed: number }> {
|
||||||
|
const items = await query<{ id: string }>(
|
||||||
|
`SELECT i.id FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.status='done' AND i.result_path IS NOT NULL
|
||||||
|
AND i.delivery_status IN ('delivered','failed') AND j.private = false
|
||||||
|
ORDER BY i.created_at`);
|
||||||
|
const { deliverItem } = await import('./delivery.ts');
|
||||||
|
let ok = 0, failed = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
const r = await deliverItem(it.id).catch(() => ({ ok: false }));
|
||||||
|
r.ok ? ok++ : failed++;
|
||||||
|
}
|
||||||
|
return { attempted: items.length, ok, failed };
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { one, query } from './db';
|
||||||
|
import { hashPassword } from './auth';
|
||||||
|
import { has } from './modules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First start: create the administrator, the settings row, and a few example
|
||||||
|
* presets. Idempotent — it runs on every boot and does nothing once the
|
||||||
|
* instance has been set up.
|
||||||
|
*
|
||||||
|
* There are no built-in accounts. The very first boot creates exactly one
|
||||||
|
* administrator, from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if you set them, and
|
||||||
|
* otherwise with a random password printed once to the server log. Nothing here
|
||||||
|
* carries a name, a folder or a mailbox belonging to whoever built this.
|
||||||
|
*/
|
||||||
|
export async function seed(): Promise<void> {
|
||||||
|
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
|
||||||
|
|
||||||
|
if (has('ai')) await seedModels();
|
||||||
|
await seedAdmin();
|
||||||
|
await seedPrintPresets();
|
||||||
|
if (has('ai')) await seedRecipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ admin */
|
||||||
|
|
||||||
|
async function seedAdmin(): Promise<void> {
|
||||||
|
const existing = await one<{ n: string }>(`SELECT count(*)::text AS n FROM users`);
|
||||||
|
if (existing && Number(existing.n) > 0) return;
|
||||||
|
|
||||||
|
const username = (process.env.ADMIN_USERNAME || 'admin').trim().toLowerCase();
|
||||||
|
const given = process.env.ADMIN_PASSWORD;
|
||||||
|
// 24 bytes of base64url is 32 characters — long enough that nobody is tempted
|
||||||
|
// to leave it in place, short enough to copy out of a log by hand.
|
||||||
|
const password = given || randomBytes(24).toString('base64url');
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO users (username, role, display_name, password_hash) VALUES ($1,'admin',$2,$3)`,
|
||||||
|
[username, username, await hashPassword(password)],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (given) {
|
||||||
|
console.log(`[setup] Administrator "${username}" created with the password from ADMIN_PASSWORD.`);
|
||||||
|
} else {
|
||||||
|
// Printed once, on the first boot only. There is no way to recover it later;
|
||||||
|
// the reset flow (module "mail") or a fresh volume is the way back.
|
||||||
|
console.log(
|
||||||
|
'\n' +
|
||||||
|
' ┌────────────────────────────────────────────────────────────┐\n' +
|
||||||
|
' │ Klarbild is set up. Sign in with: │\n' +
|
||||||
|
` │ user ${username.padEnd(46)}│\n` +
|
||||||
|
` │ password ${password.padEnd(46)}│\n` +
|
||||||
|
' │ This is shown once. Change it under Account after login. │\n' +
|
||||||
|
' └────────────────────────────────────────────────────────────┘\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- models */
|
||||||
|
|
||||||
|
async function seedModels(): Promise<void> {
|
||||||
|
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM models`);
|
||||||
|
if (count && Number(count.n) === 0) {
|
||||||
|
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort) VALUES
|
||||||
|
('google/gemini-3.1-flash-image','Fast','Ready in seconds. The everyday choice.',true,true,true,0),
|
||||||
|
('google/gemini-3-pro-image','Best quality','Slower, but truer to text and fine detail.',true,false,true,1),
|
||||||
|
('black-forest-labs/flux.2-flex','Open weights (FLUX.2)','Open model, good quality. Cannot cut out to transparency.',true,false,false,2)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- recipes */
|
||||||
|
|
||||||
|
async function seedRecipes(): Promise<void> {
|
||||||
|
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM recipes`);
|
||||||
|
if (!count || Number(count.n) > 0) return;
|
||||||
|
|
||||||
|
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||||
|
const by = admin?.id ?? null;
|
||||||
|
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||||||
|
query(
|
||||||
|
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||||
|
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
||||||
|
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
|
||||||
|
|
||||||
|
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||||
|
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||||||
|
await R('TV frame (16:9)', ['clean', 'format'], 'theframe', 'landscape');
|
||||||
|
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- print presets */
|
||||||
|
|
||||||
|
async function seedPrintPresets(): Promise<void> {
|
||||||
|
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||||
|
const by = admin?.id ?? null;
|
||||||
|
const P = async (name: string, config: Record<string, unknown>) => {
|
||||||
|
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||||||
|
if (exists) return;
|
||||||
|
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||||||
|
[name, by, JSON.stringify(config)]);
|
||||||
|
};
|
||||||
|
const sheet = {
|
||||||
|
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||||||
|
autoGap: true, gapMm: 14, bleedMm: 0,
|
||||||
|
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||||||
|
};
|
||||||
|
const F = (sizeId: string, count: number, landscape = false) =>
|
||||||
|
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||||||
|
|
||||||
|
// The classic school-photo set from a single portrait.
|
||||||
|
await P('School photo set (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||||||
|
// Just the small prints, for when the large one is already done.
|
||||||
|
await P('Small prints (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||||||
|
// A full sheet of passport photos — continuous lines, because every cell is identical.
|
||||||
|
await P('Passport sheet 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||||||
|
// Two images at true size on one sheet of 10×15 photo paper.
|
||||||
|
await P('2 × 10×7.5 on 10×15 photo paper', {
|
||||||
|
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||||||
|
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// Share links: make one image or a collection publicly reachable without the
|
||||||
|
// recipient needing an account.
|
||||||
|
//
|
||||||
|
// Ground rule: there are children in these pictures. A link that is valid
|
||||||
|
// forever and that anyone can forward is therefore the wrong default. So every
|
||||||
|
// link gets an **expiry date** of its own accord, can be **revoked** at any
|
||||||
|
// time, can be given a **passphrase**, and the page forbids search engines from
|
||||||
|
// indexing it.
|
||||||
|
import { randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { one, query } from './db.ts';
|
||||||
|
|
||||||
|
/** Without i, l, 0, O, 1 — so a link read out loud does not arrive wrong. */
|
||||||
|
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||||
|
|
||||||
|
/** 12 characters out of 31 = around 59 bits. Not guessable, still readable aloud. */
|
||||||
|
export function neuerSlug(length = 12): string {
|
||||||
|
const bytes = randomBytes(length * 2);
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; s.length < length && i < bytes.length; i++) {
|
||||||
|
const v = bytes[i];
|
||||||
|
// Discard the leftover values, otherwise the earlier characters would come up more often.
|
||||||
|
if (v < 256 - (256 % ALPHABET.length)) s += ALPHABET[v % ALPHABET.length];
|
||||||
|
}
|
||||||
|
return s.length === length ? s : neuerSlug(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShareKind = 'item' | 'collection';
|
||||||
|
|
||||||
|
export interface Share {
|
||||||
|
id: string; slug: string; kind: ShareKind;
|
||||||
|
title: string | null; note: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
expires_at: string | null; revoked_at: string | null;
|
||||||
|
allow_download: boolean; password_hash: string | null;
|
||||||
|
views: number; downloads: number;
|
||||||
|
last_seen_at: string | null; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareErstellen {
|
||||||
|
kind?: ShareKind;
|
||||||
|
itemIds: string[];
|
||||||
|
title?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
/** Days until expiry; 0 or null = unlimited. */
|
||||||
|
days?: number | null;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
password?: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_ITEMS = 500;
|
||||||
|
export const MAX_TITEL = 200;
|
||||||
|
export const MAX_NOTIZ = 2000;
|
||||||
|
const MAX_DAYS = 3650;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the days until expiry properly.
|
||||||
|
*
|
||||||
|
* `Number('abc')` is `NaN`, and `NaN > 0` is `false` — with the naive spelling
|
||||||
|
* every nonsensical input ended up in the "unlimited" branch. Exactly the
|
||||||
|
* default this file calls wrong at the top. So: anything that is not a sensible
|
||||||
|
* number is rejected instead of being silently interpreted.
|
||||||
|
*/
|
||||||
|
export function tageLesen(v: unknown, fallback: number): number {
|
||||||
|
if (v === undefined || v === null || v === '') return fallback;
|
||||||
|
// Accept only a number or a string: `Number([])` is 0 and `Number([7])` is 7 —
|
||||||
|
// an array sent along by accident would otherwise silently turn into
|
||||||
|
// "unlimited" or into an invented deadline.
|
||||||
|
if (typeof v !== 'number' && typeof v !== 'string')
|
||||||
|
throw new Error('Validity must be a number of days.');
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) throw new Error('Validity must be a number of days.');
|
||||||
|
const t = Math.trunc(n);
|
||||||
|
if (t < 0 || t > MAX_DAYS) throw new Error(`Validity must be between 0 and ${MAX_DAYS} days.`);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const short = (v: unknown, max: number): string | null => {
|
||||||
|
const t = String(v ?? '').trim();
|
||||||
|
return t ? t.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a link. Without an explicit value the default from the settings
|
||||||
|
* applies (30 days) — not "unlimited".
|
||||||
|
*/
|
||||||
|
export async function createShare(input: ShareErstellen): Promise<Share> {
|
||||||
|
const ids = [...new Set(input.itemIds.filter(Boolean))].slice(0, MAX_ITEMS);
|
||||||
|
if (!ids.length) throw new Error('No images selected.');
|
||||||
|
|
||||||
|
const s = await one<{ share_default_days: number }>('SELECT share_default_days FROM settings WHERE id=1');
|
||||||
|
const days = tageLesen(input.days, s?.share_default_days ?? 30);
|
||||||
|
const expires = days > 0 ? new Date(Date.now() + days * 86400_000) : null;
|
||||||
|
const kind: ShareKind = input.kind === 'collection' || ids.length > 1 ? 'collection' : 'item';
|
||||||
|
const pw = input.password?.trim() ? await argon2.hash(input.password.trim()) : null;
|
||||||
|
|
||||||
|
const share = await one<Share>(
|
||||||
|
`INSERT INTO shares (slug, kind, title, note, created_by, expires_at, allow_download, password_hash)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
|
[neuerSlug(), kind, short(input.title, MAX_TITEL), short(input.note, MAX_NOTIZ),
|
||||||
|
input.userId, expires, input.allowDownload !== false, pw]);
|
||||||
|
|
||||||
|
// Insert in **one** step: before it was up to 500 separate queries, and if one
|
||||||
|
// of them failed (because an image had been deleted in the meantime, say), a
|
||||||
|
// half-filled link was left behind while the interface reported an error.
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO share_items (share_id, item_id, position)
|
||||||
|
SELECT $1, x.id, x.pos FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id, pos)
|
||||||
|
ON CONFLICT DO NOTHING`, [share!.id, ids]);
|
||||||
|
} catch (e) {
|
||||||
|
// No half link: whatever could not come into being completely is removed.
|
||||||
|
await query('DELETE FROM shares WHERE id = $1', [share!.id]).catch(() => {});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return share!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShareStatus = 'ok' | 'not-found' | 'revoked' | 'expired' | 'passphrase';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a link and check whether it is valid right now.
|
||||||
|
* Revoked and expired are kept **apart** — the recipient should understand why
|
||||||
|
* nothing shows up, instead of suspecting a typo.
|
||||||
|
*/
|
||||||
|
export async function resolveShare(slug: string, passphrase?: string | null): Promise<{
|
||||||
|
status: ShareStatus; share?: Share;
|
||||||
|
}> {
|
||||||
|
if (!/^[a-z0-9]{6,32}$/.test(String(slug || ''))) return { status: 'not-found' };
|
||||||
|
const share = await one<Share>('SELECT * FROM shares WHERE slug=$1', [slug]);
|
||||||
|
if (!share) return { status: 'not-found' };
|
||||||
|
if (share.revoked_at) return { status: 'revoked', share };
|
||||||
|
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now()) return { status: 'expired', share };
|
||||||
|
if (share.password_hash) {
|
||||||
|
if (!passphrase) return { status: 'passphrase', share };
|
||||||
|
let ok = false;
|
||||||
|
try { ok = await argon2.verify(share.password_hash, passphrase); } catch { ok = false; }
|
||||||
|
if (!ok) return { status: 'passphrase', share };
|
||||||
|
}
|
||||||
|
return { status: 'ok', share };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareBild {
|
||||||
|
id: string; filename: string | null; output_px: string | null; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The images behind a link, in the stored order. */
|
||||||
|
export async function shareItems(shareId: string): Promise<ShareBild[]> {
|
||||||
|
return query<ShareBild>(
|
||||||
|
// No join on `jobs`: per the schema `items.job_id` is nullable, so an INNER
|
||||||
|
// JOIN could swallow rows — and nothing from it was needed anyway.
|
||||||
|
`SELECT i.id, i.filename, i.output_px, i.created_at
|
||||||
|
FROM share_items si
|
||||||
|
JOIN items i ON i.id = si.item_id
|
||||||
|
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
|
||||||
|
ORDER BY si.position, i.created_at`, [shareId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this image belong to this link? Cross-check before every delivery. */
|
||||||
|
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
||||||
|
const r = await one<{ item_id: string }>(
|
||||||
|
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an access. The IP is stored **hashed only**, salted with the server
|
||||||
|
* secret: that makes it possible to count returning visitors, but the address
|
||||||
|
* cannot be computed back and nobody is identifiable.
|
||||||
|
*/
|
||||||
|
export async function trackShare(
|
||||||
|
shareId: string, kind: 'view' | 'download', ip?: string | null, ua?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const salt = process.env.SESSION_SECRET || 'klarbild';
|
||||||
|
// The hash takes the link's id into account: that way the same visitor can be
|
||||||
|
// recognised **within** one link, but not followed across several links.
|
||||||
|
// (Honestly: this is pseudonymisation, not anonymisation — whoever has the
|
||||||
|
// server key can work through the IPv4 space. Which is why it is only kept
|
||||||
|
// for 90 days.)
|
||||||
|
const ipHash = ip
|
||||||
|
? createHash('sha256').update(`${salt}|${shareId}|${ip}`).digest('base64url').slice(0, 22)
|
||||||
|
: null;
|
||||||
|
const hour = new Date().toISOString().slice(0, 13); // "2026-09-01T14"
|
||||||
|
try {
|
||||||
|
// At most one row per visitor and hour — otherwise a single recipient (or an
|
||||||
|
// <img> tag on someone else's page) fills up the table.
|
||||||
|
const fresh = await query<{ id: string }>(
|
||||||
|
`INSERT INTO share_views (share_id, kind, ip_hash, user_agent, hour)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id`,
|
||||||
|
[shareId, kind, ipHash, geraet(ua), hour]);
|
||||||
|
|
||||||
|
// The counter counts every call, the visitor number only new ids.
|
||||||
|
await query(
|
||||||
|
`UPDATE shares SET last_seen_at = now(),
|
||||||
|
views = views + CASE WHEN $2='view' THEN 1 ELSE 0 END,
|
||||||
|
downloads = downloads + CASE WHEN $2='download' THEN 1 ELSE 0 END,
|
||||||
|
visitor = visitor + CASE WHEN $3 AND $2='view' THEN 1 ELSE 0 END
|
||||||
|
WHERE id = $1`, [shareId, kind, fresh.length > 0]);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Counting is a side matter — it must never stop the delivery.
|
||||||
|
console.error('[share] access not counted:', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the browser and the system from the user agent.
|
||||||
|
* The full string is a usable fingerprint; for "what was it opened with"
|
||||||
|
* "Safari · iPhone" is plenty.
|
||||||
|
*/
|
||||||
|
export function geraet(ua?: string | null): string | null {
|
||||||
|
const s = String(ua || '');
|
||||||
|
if (!s) return null;
|
||||||
|
const browser = /Edg\//.test(s) ? 'Edge' : /OPR\//.test(s) ? 'Opera'
|
||||||
|
: /Chrome\//.test(s) ? 'Chrome' : /Firefox\//.test(s) ? 'Firefox'
|
||||||
|
: /Safari\//.test(s) ? 'Safari' : 'Browser';
|
||||||
|
const system = /iPhone/.test(s) ? 'iPhone' : /iPad/.test(s) ? 'iPad'
|
||||||
|
: /Android/.test(s) ? 'Android' : /Macintosh|Mac OS/.test(s) ? 'Mac'
|
||||||
|
: /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';
|
||||||
|
return system ? `${browser} · ${system}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareStat extends Share {
|
||||||
|
count: number; visitor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overview for the owner (admins see all of them). */
|
||||||
|
export async function listShares(userId: string | null, isAdmin: boolean): Promise<ShareStat[]> {
|
||||||
|
return query<ShareStat>(
|
||||||
|
// `count` counts the same conditions as `shareItems` — otherwise the
|
||||||
|
// management view names a different number than the page the recipient sees.
|
||||||
|
// `visitor` comes from the maintained column instead of an aggregation over
|
||||||
|
// share_views: with many accesses this page turned sluggish otherwise.
|
||||||
|
`SELECT s.*,
|
||||||
|
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
||||||
|
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS count,
|
||||||
|
s.visitor
|
||||||
|
FROM shares s
|
||||||
|
WHERE $2::boolean OR s.created_by = $1
|
||||||
|
ORDER BY s.created_at DESC LIMIT 200`, [userId, isAdmin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke — the link stays in place, but delivers nothing any more. */
|
||||||
|
export async function revokeShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||||
|
const r = await query<{ id: string }>(
|
||||||
|
`UPDATE shares SET revoked_at = now()
|
||||||
|
WHERE id = $1 AND ($3::boolean OR created_by = $2) AND revoked_at IS NULL RETURNING id`,
|
||||||
|
[id, userId, isAdmin]);
|
||||||
|
return r.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release it again — handy when you clicked the wrong thing. */
|
||||||
|
export async function unrevokeShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||||
|
const r = await query<{ id: string }>(
|
||||||
|
`UPDATE shares SET revoked_at = NULL
|
||||||
|
WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, [id, userId, isAdmin]);
|
||||||
|
return r.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareAendern {
|
||||||
|
title?: string | null; note?: string | null;
|
||||||
|
days?: number | null; allowDownload?: boolean;
|
||||||
|
/** Empty text removes the passphrase. */
|
||||||
|
password?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShare(
|
||||||
|
id: string, userId: string | null, isAdmin: boolean, a: ShareAendern,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const own = await one<{ id: string }>(
|
||||||
|
'SELECT id FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2)', [id, userId, isAdmin]);
|
||||||
|
if (!own) return false;
|
||||||
|
|
||||||
|
const sets: string[] = []; const args: any[] = [id];
|
||||||
|
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
|
||||||
|
if (a.title !== undefined) set('title', short(a.title, MAX_TITEL));
|
||||||
|
if (a.note !== undefined) set('note', short(a.note, MAX_NOTIZ));
|
||||||
|
if (a.allowDownload !== undefined) set('allow_download', !!a.allowDownload);
|
||||||
|
if (a.days !== undefined) {
|
||||||
|
const t = tageLesen(a.days, 0);
|
||||||
|
set('expires_at', t > 0 ? new Date(Date.now() + t * 86400_000) : null);
|
||||||
|
}
|
||||||
|
if (a.password !== undefined) {
|
||||||
|
set('password_hash', a.password?.trim() ? await argon2.hash(a.password.trim()) : null);
|
||||||
|
}
|
||||||
|
if (!sets.length) return true;
|
||||||
|
await query(`UPDATE shares SET ${sets.join(',')} WHERE id=$1`, args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||||
|
const r = await query<{ id: string }>(
|
||||||
|
'DELETE FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2) RETURNING id', [id, userId, isAdmin]);
|
||||||
|
return r.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time comparison — its duration does not give away how close you were. */
|
||||||
|
export function gleich(a: string, b: string): boolean {
|
||||||
|
const x = Buffer.from(String(a)); const y = Buffer.from(String(b));
|
||||||
|
if (x.length !== y.length) return false;
|
||||||
|
return timingSafeEqual(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Names inside the collected package ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique file names within one ZIP.
|
||||||
|
*
|
||||||
|
* Two identical names in the same archive are not an error while packing — they
|
||||||
|
* only show up when **unpacking**, where the second overwrites the first or the
|
||||||
|
* unpacker asks back. Both annoying, both avoidable.
|
||||||
|
*
|
||||||
|
* `taken` is written on as it goes; the function is therefore deliberately not
|
||||||
|
* pure but works on the state of the running archive.
|
||||||
|
*/
|
||||||
|
export function archiveName(
|
||||||
|
raw: string | null | undefined, taken: Map<string, number>, defaultExt = 'jpg',
|
||||||
|
): string {
|
||||||
|
let name = String(raw || '').replace(/[/\\]/g, '_').replace(/[\u0000-\u001f]/g, '').trim()
|
||||||
|
|| `image.${defaultExt}`;
|
||||||
|
if (!/\.[a-z0-9]{1,8}$/i.test(name)) name += `.${defaultExt}`;
|
||||||
|
const n = taken.get(name);
|
||||||
|
if (n == null) { taken.set(name, 0); return name; }
|
||||||
|
taken.set(name, n + 1);
|
||||||
|
return name.replace(/(\.[^.]+)$/, `_${n + 1}$1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File name for the collected package: date and title, reduced to file-system characters. */
|
||||||
|
export function packageName(title: string | null | undefined, on = new Date()): string {
|
||||||
|
const slug = String(title || '').toLowerCase()
|
||||||
|
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
|
||||||
|
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'klarbild';
|
||||||
|
return `${on.toISOString().slice(0, 10)}_${slug}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Proof of a checked passphrase ----------------
|
||||||
|
* `argon2.verify` costs around 130 ms and 64 MiB — and used to run on **every**
|
||||||
|
* request, so on every single image of a folder too. A page with 40 images
|
||||||
|
* would have driven the process into the memory limit, and a few dozen requests
|
||||||
|
* with the wrong word would have brought the whole application to a halt.
|
||||||
|
*
|
||||||
|
* So: check once, then carry along a short-lived, signed proof that is valid
|
||||||
|
* **for this one link only**. */
|
||||||
|
|
||||||
|
const PROOF_LIFETIME = 6 * 60 * 60; // 6 hours
|
||||||
|
|
||||||
|
export const proofName = (slug: string) => `kb_s_${slug}`;
|
||||||
|
|
||||||
|
export function proofBuild(shareId: string): string {
|
||||||
|
const until = Math.floor(Date.now() / 1000) + PROOF_LIFETIME;
|
||||||
|
const sig = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
|
||||||
|
.update(`${shareId}.${until}`).digest('base64url');
|
||||||
|
return `${until}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proofValid(shareId: string, value?: string | null): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const [untilRaw, sig] = String(value).split('.');
|
||||||
|
const until = Number(untilRaw);
|
||||||
|
if (!Number.isFinite(until) || until < Math.floor(Date.now() / 1000)) return false;
|
||||||
|
const expected = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
|
||||||
|
.update(`${shareId}.${until}`).digest('base64url');
|
||||||
|
return !!sig && gleich(sig, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the proof out of the cookie header. */
|
||||||
|
export function proofFromCookie(cookieHeader: string | null | undefined, slug: string): string | null {
|
||||||
|
const name = proofName(slug);
|
||||||
|
return cookieHeader?.split(/;\s*/).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1) || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Object storage abstraction. STORAGE_DRIVER=fs (volume, default) | s3 (MinIO/S3).
|
||||||
|
// Presigned URLs are not needed — originals go to the image API as a base64 data URL.
|
||||||
|
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||||
|
const DIR = process.env.STORAGE_DIR || '/data';
|
||||||
|
const bucket = process.env.S3_BUCKET || 'klarbild';
|
||||||
|
|
||||||
|
// --- S3 (lazy, only when used) ----------------------------------------------
|
||||||
|
let _s3: any = null;
|
||||||
|
async function s3client() {
|
||||||
|
if (!_s3) {
|
||||||
|
const { S3Client } = await import('@aws-sdk/client-s3');
|
||||||
|
_s3 = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'true') === 'true',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _s3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API --------------------------------------------------------------------
|
||||||
|
export async function ensureBucket(): Promise<void> {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { CreateBucketCommand, HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
const s3 = await s3client();
|
||||||
|
try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); }
|
||||||
|
catch { await s3.send(new CreateBucketCommand({ Bucket: bucket })); }
|
||||||
|
} else {
|
||||||
|
await mkdir(DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putObject(key: string, body: Buffer, contentType: string): Promise<string> {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
||||||
|
} else {
|
||||||
|
const p = join(DIR, key);
|
||||||
|
await mkdir(dirname(p), { recursive: true });
|
||||||
|
await writeFile(p, body);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getObject(key: string): Promise<Buffer> {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
const res = await (await s3client()).send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of res.Body as any) chunks.push(Buffer.from(c));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
return readFile(join(DIR, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
} else {
|
||||||
|
await unlink(join(DIR, key)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage health check (directory writable, or bucket reachable). */
|
||||||
|
export async function storageOk(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
} else {
|
||||||
|
await mkdir(DIR, { recursive: true });
|
||||||
|
const probe = join(DIR, '.health');
|
||||||
|
await writeFile(probe, 'ok'); await unlink(probe).catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceKey(uuid: string, ext: string): string {
|
||||||
|
return `sources/${new Date().getFullYear()}/${uuid}.${ext}`;
|
||||||
|
}
|
||||||
|
export function resultKey(uuid: string): string {
|
||||||
|
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
||||||
|
}
|
||||||
|
export function thumbKey(uuid: string): string {
|
||||||
|
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
// Telegram bot (grammY, webhook) — a full input and control channel.
|
||||||
|
// Pairing, bundling of forwarded images, preset recipe choice, processing, feedback.
|
||||||
|
import { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { one, query } from './db';
|
||||||
|
import { decrypt } from './crypto';
|
||||||
|
import { getObject, putObject, sourceKey } from './storage';
|
||||||
|
import { enqueue } from './queue';
|
||||||
|
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
|
||||||
|
import { renderCell, buildSheetPdf } from './printrender';
|
||||||
|
import { photoById, labelMm, paperById } from './paper';
|
||||||
|
|
||||||
|
const QUIET_MS = 4000; // quiet window for the bundling
|
||||||
|
const BASE = process.env.PUBLIC_BASE_URL || '';
|
||||||
|
|
||||||
|
let bot: Bot | null = null;
|
||||||
|
let webhookSecret = '';
|
||||||
|
// Scratch space: free text the user can turn into an image with one button.
|
||||||
|
const pendingGenerate = new Map<number, string>();
|
||||||
|
// Chats that are typing an image description right now (after tapping “New image”).
|
||||||
|
const awaitingGenerate = new Set<number>();
|
||||||
|
|
||||||
|
// Menu labels (persistent keyboard) — everything by fingertip, without commands.
|
||||||
|
const BTN_NEW = '✨ New image', BTN_RECIPES = '📁 Choose recipe', BTN_HELP = '❓ Help';
|
||||||
|
function mainKeyboard() {
|
||||||
|
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||||
|
}
|
||||||
|
// Print sheet via Telegram: a fixed, tappable choice — no free text needed.
|
||||||
|
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
|
||||||
|
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = “fill the sheet”
|
||||||
|
|
||||||
|
const HELP_TEXT =
|
||||||
|
'How it works — all by button, without commands:\n\n' +
|
||||||
|
'🖼 *Edit:* send the image or images here (best as a *file*), then tap the preset recipe at the bottom.\n' +
|
||||||
|
'🔀 *Combine:* send 2+ images, put a description such as “with our dog” in the image caption field — then tap “🔀 Combine”.\n' +
|
||||||
|
'✨ *New image:* tap “✨ New image” at the bottom and describe what should come out.\n' +
|
||||||
|
'♻️ *Keep editing:* simply send a finished image from me back to me.\n' +
|
||||||
|
'🖨 *Print (without AI):* send an image → “🖨 Print” → tap the size and the quantity. ' +
|
||||||
|
'You get a PDF at 100 % size with crop marks.\n\n' +
|
||||||
|
'I report back once, when everything is done — with the result and a link.';
|
||||||
|
|
||||||
|
export async function getToken(): Promise<string> {
|
||||||
|
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
|
||||||
|
if (s?.telegram_bot_token_enc) { try { return decrypt(s.telegram_bot_token_enc); } catch { /* ENV */ } }
|
||||||
|
return process.env.TELEGRAM_BOT_TOKEN || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBot(): Promise<Bot | null> {
|
||||||
|
if (bot) return bot;
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
bot = new Bot(token);
|
||||||
|
register(bot);
|
||||||
|
await bot.init();
|
||||||
|
return bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebhookSecret(): string { return webhookSecret; }
|
||||||
|
|
||||||
|
/** At startup: register the webhook (secret from settings) + the bundling sweeper. */
|
||||||
|
export async function setupTelegram(): Promise<void> {
|
||||||
|
const b = await getBot();
|
||||||
|
if (!b) { console.log('[telegram] no token — bot inactive'); return; }
|
||||||
|
const s = await one<{ telegram_webhook_secret: string | null }>('SELECT telegram_webhook_secret FROM settings WHERE id=1');
|
||||||
|
webhookSecret = s?.telegram_webhook_secret || randomUUID().replace(/-/g, '');
|
||||||
|
if (!s?.telegram_webhook_secret) await query('UPDATE settings SET telegram_webhook_secret=$1 WHERE id=1', [webhookSecret]);
|
||||||
|
if (BASE) {
|
||||||
|
try {
|
||||||
|
await b.api.setWebhook(`${BASE}/api/telegram/webhook`, {
|
||||||
|
secret_token: webhookSecret, allowed_updates: ['message', 'callback_query'], drop_pending_updates: true,
|
||||||
|
});
|
||||||
|
console.log('[telegram] webhook set:', `${BASE}/api/telegram/webhook`);
|
||||||
|
} catch (e) { console.error('[telegram] setWebhook failed:', e); }
|
||||||
|
}
|
||||||
|
// Command menu (the “/” menu in Telegram) — as an additional shortcut.
|
||||||
|
try {
|
||||||
|
await b.api.setMyCommands([
|
||||||
|
{ command: 'new', description: '✨ Generate a new image from text' },
|
||||||
|
{ command: 'presets', description: '📁 Choose the default preset recipe' },
|
||||||
|
{ command: 'status', description: '⏳ Show the last job' },
|
||||||
|
{ command: 'help', description: '❓ Help & guide' },
|
||||||
|
{ command: 'start', description: '🏠 Show the menu' },
|
||||||
|
]);
|
||||||
|
} catch (e) { console.error('[telegram] setMyCommands:', e); }
|
||||||
|
setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pairing ----------------------------------------------------------------
|
||||||
|
async function linkedUser(chatId: number) {
|
||||||
|
return one<{ user_id: string; default_recipe_id: string | null }>(
|
||||||
|
'SELECT user_id, default_recipe_id FROM telegram_links WHERE chat_id=$1 AND active', [chatId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryPair(chatId: number, code: string): Promise<boolean> {
|
||||||
|
const row = await one<{ user_id: string }>(
|
||||||
|
`SELECT user_id FROM telegram_pairing_codes WHERE code=$1 AND used_at IS NULL AND expires_at > now()`, [code.trim()]);
|
||||||
|
if (!row) return false;
|
||||||
|
await query(`UPDATE telegram_pairing_codes SET used_at=now() WHERE code=$1`, [code.trim()]);
|
||||||
|
await query(`INSERT INTO telegram_links (chat_id, user_id, active) VALUES ($1,$2,true)
|
||||||
|
ON CONFLICT (chat_id) DO UPDATE SET user_id=$2, active=true`, [chatId, row.user_id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bundling (drafts) ------------------------------------------------------
|
||||||
|
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
|
||||||
|
const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
|
||||||
|
`SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
|
||||||
|
if (existing) {
|
||||||
|
const refs = [...(existing.file_refs || []), fileRef];
|
||||||
|
// Keep the first caption that came in as the description.
|
||||||
|
const cap = existing.caption || (caption?.trim() || null);
|
||||||
|
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
|
||||||
|
[existing.id, JSON.stringify(refs), cap]);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
const row = await one<{ id: string }>(
|
||||||
|
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
|
||||||
|
VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
|
||||||
|
[chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
|
||||||
|
return row!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sweeper: drafts that finished collecting (quiet window over) → ask for the preset recipe. */
|
||||||
|
export async function sweepDrafts(): Promise<void> {
|
||||||
|
const b = await getBot(); if (!b) return;
|
||||||
|
const drafts = await query<any>(
|
||||||
|
`SELECT id, chat_id, file_refs, caption FROM telegram_drafts
|
||||||
|
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
|
||||||
|
for (const d of drafts) {
|
||||||
|
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
|
||||||
|
const kb = new InlineKeyboard();
|
||||||
|
// Callback data <64 bytes: only the recipe UUID; the draft is found via the chat on click.
|
||||||
|
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||||
|
const n = (d.file_refs || []).length;
|
||||||
|
// From 2 images on, additionally offer “Combine”.
|
||||||
|
if (n >= 2) { kb.row(); kb.text('🔀 Combine into one image', 'c:x'); }
|
||||||
|
kb.row(); kb.text('🖨 Print (without AI)', 'p:menu');
|
||||||
|
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||||
|
const capNote = d.caption ? `\n📝 Description recognised: “${d.caption}” — for “Combine”.` : '';
|
||||||
|
try {
|
||||||
|
await b.api.sendMessage(d.chat_id,
|
||||||
|
`${n} image${n > 1 ? 's' : ''} received. What would you like to do?` + capNote +
|
||||||
|
(compressed ? '\n⚠️ Sent as a photo (compressed). For full quality send as a *file*.' : ''),
|
||||||
|
{ reply_markup: kb, parse_mode: 'Markdown' });
|
||||||
|
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
|
||||||
|
} catch (e) { console.error('[telegram] sweep send:', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draft -> job -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Loads all images of a draft from Telegram and puts them into the object store. */
|
||||||
|
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
|
||||||
|
const sources: any[] = [];
|
||||||
|
const token = await getToken();
|
||||||
|
for (const ref of refs || []) {
|
||||||
|
try {
|
||||||
|
const file = await b.api.getFile(ref.file_id);
|
||||||
|
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||||
|
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
|
||||||
|
const key = sourceKey(randomUUID(), 'jpg');
|
||||||
|
await putObject(key, buf, 'image/jpeg');
|
||||||
|
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
|
||||||
|
} catch (e) { console.error('[telegram] loading the file failed', e); }
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base snapshot from the chat's default preset recipe (format/model/delivery). */
|
||||||
|
async function chatRecipeDefaults(chatId: number): Promise<any> {
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
if (link?.default_recipe_id) {
|
||||||
|
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
|
||||||
|
if (r) return {
|
||||||
|
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
|
||||||
|
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
|
||||||
|
delivery_folder: r.delivery_folder, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
||||||
|
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||||
|
if (!draft || draft.status === 'dispatched') return;
|
||||||
|
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
||||||
|
if (!recipe) { await b.api.sendMessage(chatId, 'Preset recipe not found.'); return; }
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
|
||||||
|
await b.api.sendMessage(chatId, `⏳ Processing ${(draft.file_refs || []).length} image(s) …`);
|
||||||
|
const sources = await refsToSources(draft.file_refs || [], b);
|
||||||
|
if (!sources.length) { await b.api.sendMessage(chatId, '❌ No images could be loaded.'); return; }
|
||||||
|
|
||||||
|
const snap = {
|
||||||
|
tasks: recipe.tasks, output_format: recipe.output_format, orientation: recipe.orientation,
|
||||||
|
crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm,
|
||||||
|
model_key: recipe.model_key, custom_instruction: recipe.custom_instruction,
|
||||||
|
delivery: recipe.delivery || 'library', delivery_folder: recipe.delivery_folder,
|
||||||
|
output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id,
|
||||||
|
};
|
||||||
|
const job = await one<{ id: string }>(
|
||||||
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
|
VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
|
||||||
|
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
|
||||||
|
for (let i = 0; i < sources.length; i++) {
|
||||||
|
const it = await one<{ id: string }>(
|
||||||
|
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
|
||||||
|
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
|
||||||
|
[job!.id, i, sources[i].source_path, sources[i].filename, sources[i].quality]);
|
||||||
|
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||||
|
}
|
||||||
|
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine: all images of the draft + the description → one new image. */
|
||||||
|
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
|
||||||
|
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||||
|
if (!draft || draft.status === 'dispatched') return;
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
await b.api.sendMessage(chatId, '⏳ Combining the images …');
|
||||||
|
const sources = await refsToSources(draft.file_refs || [], b);
|
||||||
|
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ To combine I need at least 2 images.'); return; }
|
||||||
|
|
||||||
|
const d = await chatRecipeDefaults(chatId);
|
||||||
|
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||||
|
const snap = {
|
||||||
|
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||||
|
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||||
|
prompt_text: description, delivery: d.delivery || 'library', delivery_folder: d.delivery_folder,
|
||||||
|
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||||
|
};
|
||||||
|
const job = await one<{ id: string }>(
|
||||||
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
|
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
|
||||||
|
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||||
|
const it = await one<{ id: string }>(
|
||||||
|
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||||
|
VALUES ($1,0,'queued',$2,'combined','original') RETURNING id`,
|
||||||
|
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
|
||||||
|
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||||
|
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free text: a completely new image from the description alone. */
|
||||||
|
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
await b.api.sendMessage(chatId, '⏳ Generating a new image …');
|
||||||
|
const d = await chatRecipeDefaults(chatId);
|
||||||
|
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||||
|
const snap = {
|
||||||
|
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||||
|
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||||
|
prompt_text: description, delivery: d.delivery || 'library', delivery_folder: d.delivery_folder,
|
||||||
|
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||||
|
};
|
||||||
|
const job = await one<{ id: string }>(
|
||||||
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
|
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
|
||||||
|
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||||
|
const it = await one<{ id: string }>(
|
||||||
|
`INSERT INTO items (job_id, position, status, filename, source_quality)
|
||||||
|
VALUES ($1,0,'queued','new','original') RETURNING id`, [job!.id]);
|
||||||
|
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print sheet without AI: bring the draft's images to a size, place them on A4,
|
||||||
|
* add crop marks, send it back as a PDF. Runs directly (no queue, no model,
|
||||||
|
* no cost).
|
||||||
|
*/
|
||||||
|
async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) {
|
||||||
|
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||||
|
if (!draft) { await b.api.sendMessage(chatId, 'No open image batch found.'); return; }
|
||||||
|
const size = photoById(sizeId);
|
||||||
|
if (!size) { await b.api.sendMessage(chatId, 'Unknown size.'); return; }
|
||||||
|
|
||||||
|
await b.api.sendMessage(chatId, '📐 Building the print sheet …');
|
||||||
|
const sources = await refsToSources(draft.file_refs || [], b);
|
||||||
|
if (!sources.length) { await b.api.sendMessage(chatId, '❌ No images could be loaded.'); return; }
|
||||||
|
|
||||||
|
const a4 = paperById('A4')!;
|
||||||
|
const sheet: SheetSpec = {
|
||||||
|
wMm: a4.w, hMm: a4.h, marginMm: 5,
|
||||||
|
gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true,
|
||||||
|
};
|
||||||
|
// “Fill the sheet”: as often as possible, spread evenly over the images.
|
||||||
|
let per = count;
|
||||||
|
if (!per) {
|
||||||
|
const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet);
|
||||||
|
per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length));
|
||||||
|
}
|
||||||
|
const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true }));
|
||||||
|
const plan = layout(specs, sheet);
|
||||||
|
if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ The size does not fit on A4.'); return; }
|
||||||
|
|
||||||
|
const images: Record<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
|
||||||
|
for (const [i, src] of sources.entries()) {
|
||||||
|
const buf = await getObject(src.source_path);
|
||||||
|
const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated)));
|
||||||
|
for (const rot of rots) {
|
||||||
|
const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot });
|
||||||
|
images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pages = plan.pages.map((pg) => ({
|
||||||
|
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||||
|
}));
|
||||||
|
const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 }));
|
||||||
|
const label = labelMm(size.w, size.h);
|
||||||
|
const bytes = await buildSheetPdf({
|
||||||
|
sheet, pages, marksPerPage: marks, images, title: `Klarbild print sheet ${label}`,
|
||||||
|
footer: {
|
||||||
|
text: `Klarbild · DIN A4 · ${label} · 300 dpi · print at 100 % (not “fit to page”)`,
|
||||||
|
position: 'bottom-left', sizePt: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = new InputFile(Buffer.from(bytes), `klarbild-print-sheet-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
||||||
|
await b.api.sendDocument(chatId, file, {
|
||||||
|
caption: `📐 ${plan.pages.length} sheet(s) · ${plan.pages[0].placements.length} image(s) on sheet 1 · ${label}\n` +
|
||||||
|
'When printing choose “Actual size / 100 %” — otherwise the size is wrong.',
|
||||||
|
});
|
||||||
|
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the worker: one message back to the chat when the job is done. */
|
||||||
|
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
||||||
|
const b = await getBot(); if (!b) return;
|
||||||
|
const items = await query<any>(
|
||||||
|
`SELECT id, filename, result_path, status, delivery_status FROM items WHERE job_id=$1 ORDER BY position`, [jobId]);
|
||||||
|
const done = items.filter((i) => i.status === 'done');
|
||||||
|
const failed = items.filter((i) => i.status === 'failed');
|
||||||
|
const delivered = items.filter((i) => i.delivery_status === 'delivered').length;
|
||||||
|
|
||||||
|
// Send the results back as files (as long as the number is manageable)
|
||||||
|
if (done.length <= 10) {
|
||||||
|
for (const it of done) {
|
||||||
|
try {
|
||||||
|
const buf = await getObject(it.result_path);
|
||||||
|
await b.api.sendDocument(chatId, new InputFile(buf, it.filename || 'klarbild.png'));
|
||||||
|
} catch (e) { console.error('[telegram] sending', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let msg = `✅ Done: ${done.length} image(s)${failed.length ? `, ${failed.length} failed` : ''}.`;
|
||||||
|
if (delivered) msg += `\n📁 ${delivered} delivered to the folder on the target.`;
|
||||||
|
if (BASE) msg += `\n🔗 Library: ${BASE}/library`;
|
||||||
|
if (done.length) msg += `\n♻️ Keep editing? Just send a result back.`;
|
||||||
|
await b.api.sendMessage(chatId, msg, { reply_markup: mainKeyboard() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handler ----------------------------------------------------------------
|
||||||
|
function register(b: Bot) {
|
||||||
|
const sendRecipeMenu = async (ctx: any) => {
|
||||||
|
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
|
||||||
|
const kb = new InlineKeyboard();
|
||||||
|
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||||
|
return ctx.reply('Which preset recipe should apply to new images by default?', { reply_markup: kb });
|
||||||
|
};
|
||||||
|
|
||||||
|
b.command('start', async (ctx) => {
|
||||||
|
const link = await linkedUser(ctx.chat.id);
|
||||||
|
if (link) return ctx.reply('👋 Klarbild is connected. Send me images to edit/combine, or tap “✨ New image” at the bottom.', { reply_markup: mainKeyboard() });
|
||||||
|
return ctx.reply('👋 Welcome to Klarbild. To pair, enter the *pairing code* from the admin area here.', { parse_mode: 'Markdown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendHelp = (ctx: any) => ctx.reply(HELP_TEXT, { parse_mode: 'Markdown', reply_markup: mainKeyboard() });
|
||||||
|
b.command('help', sendHelp);
|
||||||
|
|
||||||
|
b.command('new', async (ctx) => {
|
||||||
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first (code from the admin area).');
|
||||||
|
const text = (ctx.match || '').toString().trim();
|
||||||
|
if (!text) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Describe your image briefly — what should come out?'); }
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.command('presets', async (ctx) => {
|
||||||
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first (code from the admin area).');
|
||||||
|
return sendRecipeMenu(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.command('status', async (ctx) => {
|
||||||
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first.');
|
||||||
|
const j = await one<any>(`SELECT status, done_count, failed_count, total FROM jobs
|
||||||
|
WHERE telegram_chat_id=$1 ORDER BY created_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||||
|
if (!j) return ctx.reply('No jobs yet.');
|
||||||
|
return ctx.reply(`Last job: ${j.status} — ${j.done_count}/${j.total} done${j.failed_count ? `, ${j.failed_count} failed` : ''}.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
|
||||||
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Not paired. Please enter the pairing code from the admin area.');
|
||||||
|
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
|
||||||
|
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
|
||||||
|
};
|
||||||
|
b.on('message:photo', async (ctx) => {
|
||||||
|
const p = ctx.message.photo[ctx.message.photo.length - 1];
|
||||||
|
await onImage(ctx, p.file_id, 'photo.jpg', 'compressed');
|
||||||
|
});
|
||||||
|
b.on('message:document', async (ctx) => {
|
||||||
|
const d = ctx.message.document;
|
||||||
|
if (!(d.mime_type || '').startsWith('image/')) return;
|
||||||
|
await onImage(ctx, d.file_id, d.file_name || 'image', 'original');
|
||||||
|
});
|
||||||
|
|
||||||
|
b.on('message:text', async (ctx) => {
|
||||||
|
if (ctx.message.text.startsWith('/')) return;
|
||||||
|
const text = ctx.message.text.trim();
|
||||||
|
const linked = await linkedUser(ctx.chat.id);
|
||||||
|
if (linked) {
|
||||||
|
// Buttons of the persistent keyboard
|
||||||
|
if (text === BTN_HELP) return sendHelp(ctx);
|
||||||
|
if (text === BTN_RECIPES) return sendRecipeMenu(ctx);
|
||||||
|
if (text === BTN_NEW) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Describe your image briefly — what should come out?'); }
|
||||||
|
|
||||||
|
// Is a combine draft waiting for the description? → take the text as the description.
|
||||||
|
const waiting = await one<{ id: string }>(
|
||||||
|
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||||
|
if (waiting) {
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, text, b2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The user tapped “New image” and is typing the description now.
|
||||||
|
if (awaitingGenerate.has(ctx.chat.id)) {
|
||||||
|
awaitingGenerate.delete(ctx.chat.id);
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise: offer the free text as something to generate an image from.
|
||||||
|
const kb = new InlineKeyboard().text('✨ Yes, generate an image from it', 'g:x');
|
||||||
|
pendingGenerate.set(ctx.chat.id, text);
|
||||||
|
return ctx.reply('Should I generate a new image from that?', { reply_markup: kb });
|
||||||
|
}
|
||||||
|
if (await tryPair(ctx.chat.id, text)) {
|
||||||
|
return ctx.reply('✅ Connected. Send me images or tap “✨ New image” at the bottom.', { reply_markup: mainKeyboard() });
|
||||||
|
}
|
||||||
|
return ctx.reply('Code invalid or expired. Generate a new code in the admin area.');
|
||||||
|
});
|
||||||
|
|
||||||
|
b.on('callback_query:data', async (ctx) => {
|
||||||
|
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
// Buttons need an active pairing too — otherwise an old chat keeps working.
|
||||||
|
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Not (any longer) paired. Please enter a new pairing code from the admin area.');
|
||||||
|
if (kind === 'p') { // Print sheet without AI
|
||||||
|
const draft = await one<{ id: string }>(
|
||||||
|
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||||
|
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||||
|
if (recipeId === 'menu') {
|
||||||
|
await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]);
|
||||||
|
const kb = new InlineKeyboard();
|
||||||
|
PRINT_SIZES.forEach((id, i) => {
|
||||||
|
const s = photoById(id); if (!s) return;
|
||||||
|
kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row();
|
||||||
|
});
|
||||||
|
return ctx.editMessageText('📐 What final size should the image have?', { reply_markup: kb });
|
||||||
|
}
|
||||||
|
if (extra === undefined) {
|
||||||
|
const s = photoById(recipeId);
|
||||||
|
if (!s) return ctx.editMessageText('Unknown size.');
|
||||||
|
const kb = new InlineKeyboard();
|
||||||
|
PRINT_COUNTS.forEach((n, i) => {
|
||||||
|
kb.text(n ? `${n}×` : 'Fill the sheet', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row();
|
||||||
|
});
|
||||||
|
return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — how many times on the A4 sheet?`, { reply_markup: kb });
|
||||||
|
}
|
||||||
|
await ctx.editMessageText('Got it — the sheet is being built.');
|
||||||
|
const b2 = await getBot();
|
||||||
|
if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2)
|
||||||
|
.catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Print sheet failed: ${e?.message || e}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'd') { // Set the default preset recipe
|
||||||
|
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
||||||
|
return ctx.editMessageText('Default preset recipe set.');
|
||||||
|
}
|
||||||
|
if (kind === 'r') { // Preset recipe chosen → process this chat's open draft
|
||||||
|
const draft = await one<{ id: string }>(
|
||||||
|
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||||
|
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||||
|
await ctx.editMessageText('Got it, here we go.');
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
|
||||||
|
}
|
||||||
|
if (kind === 'c') { // Combine chosen
|
||||||
|
const draft = await one<{ id: string; caption: string | null }>(
|
||||||
|
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||||
|
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||||
|
if (draft.caption?.trim()) {
|
||||||
|
await ctx.editMessageText(`Got it — combining with: “${draft.caption.trim()}”.`);
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
|
||||||
|
} else {
|
||||||
|
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
|
||||||
|
await ctx.editMessageText('Describe briefly what should come out (for example “the image, but with our dog”):');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kind === 'g') { // Free text → new image
|
||||||
|
const text = pendingGenerate.get(ctx.chat!.id);
|
||||||
|
if (!text) return ctx.editMessageText('Text no longer available — please send it again or use /new.');
|
||||||
|
pendingGenerate.delete(ctx.chat!.id);
|
||||||
|
await ctx.editMessageText('Got it, generating a new image …');
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { makeT, DICT_ELEMENT_ID, type Dict, type Locale, type TFunction } from './i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translation inside a React island.
|
||||||
|
*
|
||||||
|
* The dictionary is not bundled. The layout renders it once per page as an inert
|
||||||
|
* `<script type="application/json">` block, and this reads it from there — so an
|
||||||
|
* English instance downloads no dictionary at all, and a German one downloads it
|
||||||
|
* once, as part of the HTML it was already fetching. Reading it is synchronous,
|
||||||
|
* which matters: an island must not render English and then flicker into German.
|
||||||
|
*
|
||||||
|
* Server-side rendering has no `document`, so the island also accepts the
|
||||||
|
* dictionary as a prop. English is the floor in every case, which means no
|
||||||
|
* component is ever left without a working `t`.
|
||||||
|
*/
|
||||||
|
let cached: Dict | null | undefined;
|
||||||
|
|
||||||
|
function fromDocument(): Dict | null {
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
cached = null;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const el = document.getElementById(DICT_ELEMENT_ID);
|
||||||
|
if (el?.textContent) {
|
||||||
|
try { cached = JSON.parse(el.textContent) as Dict; } catch { cached = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useT(dict?: Dict | Locale | null): TFunction {
|
||||||
|
return useMemo(() => {
|
||||||
|
// Pages pass the dictionary; older call sites pass a locale string, which
|
||||||
|
// carries no dictionary and simply falls through to the document.
|
||||||
|
const d = dict && typeof dict === 'object' ? dict : fromDocument();
|
||||||
|
return makeT(d);
|
||||||
|
}, [dict]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Locale, TFunction, Dict };
|
||||||
+1041
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
|||||||
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
|
import { ensureInit } from './lib/init';
|
||||||
|
import { readSession, verifySession } from './lib/auth';
|
||||||
|
import { one } from './lib/db';
|
||||||
|
import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
/^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//,
|
||||||
|
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
|
||||||
|
// Forgot password — must be reachable without being signed in, otherwise it's pointless.
|
||||||
|
/^\/password/, /^\/api\/auth\/reset/,
|
||||||
|
// Share links: whoever has the link sees the images — that's what they're for.
|
||||||
|
// The check lives in the link itself (expiry, revocation, passphrase).
|
||||||
|
/^\/s\//, /^\/api\/s\//,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** API token (Authorization: Bearer …) → a synthetic admin user for /api/*. */
|
||||||
|
async function tokenUser(header: string | null): Promise<any | null> {
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(header || '');
|
||||||
|
if (!m) return null;
|
||||||
|
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
|
||||||
|
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
|
||||||
|
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||||
|
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||||
|
// Health/webhook may run even if init is still stuck — otherwise nothing works.
|
||||||
|
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
|
||||||
|
|
||||||
|
// `readSession` checks the signature and age; whether the account still exists,
|
||||||
|
// the role still matches and the session state is still valid is only decided
|
||||||
|
// by the database — otherwise a cookie would survive every password change.
|
||||||
|
let user = await verifySession(readSession(ctx.request.headers.get('cookie')));
|
||||||
|
// Programmatic access via API token (only for /api/*, except the admin area).
|
||||||
|
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
|
||||||
|
user = await tokenUser(ctx.request.headers.get('authorization'));
|
||||||
|
}
|
||||||
|
ctx.locals.user = user;
|
||||||
|
|
||||||
|
// Language for this request (see lib/i18n.ts for the order of precedence).
|
||||||
|
const wish = ctx.url.searchParams.get('lang');
|
||||||
|
const cookieLang = ctx.cookies.get(LOCALE_COOKIE)?.value ?? null;
|
||||||
|
const locale = resolveLocale({ query: wish, cookie: cookieLang, env: process.env.KLARBILD_LOCALE ?? null });
|
||||||
|
ctx.locals.locale = locale;
|
||||||
|
if (isLocale(wish) && wish !== cookieLang) {
|
||||||
|
ctx.cookies.set(LOCALE_COOKIE, wish, { path: '/', sameSite: 'lax', httpOnly: false, maxAge: 60 * 60 * 24 * 365 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = ctx.url.pathname;
|
||||||
|
const isPublic = PUBLIC_PATHS.some((r) => r.test(path));
|
||||||
|
if (!isPublic && !user) {
|
||||||
|
if (path.startsWith('/api/')) return new Response('Unauthorized', { status: 401 });
|
||||||
|
return ctx.redirect('/login');
|
||||||
|
}
|
||||||
|
if (path.startsWith('/api/admin') || path.startsWith('/admin')) {
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
if (path.startsWith('/api/')) return new Response('Forbidden', { status: 403 });
|
||||||
|
return ctx.redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withSecurityHeaders(await next());
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security headers. Astro itself builds the Content-Security-Policy at build time
|
||||||
|
* (`experimental.csp` in `astro.config.mjs`) and attaches real **hashes** for every
|
||||||
|
* inline script of ours — so no `'unsafe-inline'` for scripts, which is the
|
||||||
|
* important part against injected code.
|
||||||
|
*
|
||||||
|
* `style-src` is deliberately set back to `'unsafe-inline'` here: React sets
|
||||||
|
* styles as an attribute on the element (`style={{…}}`), and attributes cannot be
|
||||||
|
* hashed. Measured: with a pure hash policy the browser blocks them, and the
|
||||||
|
* preview in the print module loses its layout. What remains exposed — CSS-based
|
||||||
|
* exfiltration — is a much narrower class of attack than script injection, and
|
||||||
|
* that one stays closed.
|
||||||
|
*/
|
||||||
|
function withSecurityHeaders(res: Response): Response {
|
||||||
|
const csp = res.headers.get('content-security-policy');
|
||||||
|
if (csp) {
|
||||||
|
res.headers.set('content-security-policy',
|
||||||
|
csp.replace(/style-src[^;]*/i, "style-src 'self' 'unsafe-inline'"));
|
||||||
|
}
|
||||||
|
// No MIME sniffing: result files are served with a detected type, the
|
||||||
|
// browser should not guess on its own and run something as a script.
|
||||||
|
res.headers.set('x-content-type-options', 'nosniff');
|
||||||
|
// Don't send the path along when navigating to another site (folder names!).
|
||||||
|
res.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
||||||
|
// Older browsers without CSP frame-ancestors.
|
||||||
|
res.headers.set('x-frame-options', 'DENY');
|
||||||
|
// None of these interfaces are needed.
|
||||||
|
res.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import AccountApp from '../components/AccountApp';
|
||||||
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
|
const t = tFor(locale);
|
||||||
|
const dict = dictFor(locale);
|
||||||
|
---
|
||||||
|
<Base title={t('My account · Klarbild')}>
|
||||||
|
<section class="page">
|
||||||
|
<header class="pagehead">
|
||||||
|
<h1>{t('My account')}</h1>
|
||||||
|
<p class="lead">{t('Email address and notifications.')}</p>
|
||||||
|
</header>
|
||||||
|
<AccountApp client:load locale={locale} dict={dict} />
|
||||||
|
</section>
|
||||||
|
<style>
|
||||||
|
.page { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
|
||||||
|
.pagehead { margin-bottom: 18px; }
|
||||||
|
.pagehead h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||||
|
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; font-size: 0.95rem; }
|
||||||
|
@media (max-width: 640px) { .page { padding: 18px 14px 90px; } }
|
||||||
|
</style>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import AdminApp from '../../components/AdminApp.tsx';
|
||||||
|
import { tFor, dictFor } from '../../lib/i18n.server';
|
||||||
|
if (Astro.locals.user?.role !== 'admin') return Astro.redirect('/');
|
||||||
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
|
const t = tFor(locale);
|
||||||
|
const dict = dictFor(locale);
|
||||||
|
---
|
||||||
|
<Base title={t('Admin · Klarbild')}>
|
||||||
|
<h1 class="page-title">{t('Admin')}</h1>
|
||||||
|
<AdminApp client:load locale={locale} dict={dict} />
|
||||||
|
<style>.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import posixpath from 'node:path/posix';
|
||||||
|
import { loadConfig, cleanupTmp } from '../../../lib/remotetarget';
|
||||||
|
import { has, moduleOff } from '../../../lib/modules';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
/** Diagnostics: lists a folder on the delivery target (default: the base folder). Body { path? }
|
||||||
|
* Clean up: body { action:'cleanup_tmp', gallery } removes .tmp files left behind. */
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
if (!has('delivery')) return moduleOff('delivery');
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
if (!cfg) return json({ error: 'Delivery target is not configured.' }, 400);
|
||||||
|
if (b.action === 'cleanup_tmp') {
|
||||||
|
try {
|
||||||
|
const removed = await cleanupTmp(cfg, b.gallery || '');
|
||||||
|
return json({ cleaned: removed });
|
||||||
|
} catch (e: any) { return json({ error: e?.message || 'Clean-up failed.' }, 500); }
|
||||||
|
}
|
||||||
|
const path = b.path || cfg.basePath || '/';
|
||||||
|
try {
|
||||||
|
if (cfg.protocol === 'sftp') {
|
||||||
|
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||||
|
const c = new SftpClient();
|
||||||
|
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||||
|
try {
|
||||||
|
const list = await c.list(path);
|
||||||
|
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||||
|
} finally { await c.end(); }
|
||||||
|
} else {
|
||||||
|
const { Client } = await import('basic-ftp');
|
||||||
|
const c = new Client(15000);
|
||||||
|
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||||
|
try {
|
||||||
|
const list = await c.list(path);
|
||||||
|
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||||
|
} finally { c.close(); }
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return json({ path, error: e?.message || 'Listing failed.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { one, query } from '../../../lib/db';
|
||||||
|
import { encrypt } from '../../../lib/crypto';
|
||||||
|
import { loadTargetConfig, testConnection } from '../../../lib/remotetarget';
|
||||||
|
import { has, moduleOff } from '../../../lib/modules';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
if (!has('delivery')) return moduleOff('delivery');
|
||||||
|
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar,
|
||||||
|
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
|
||||||
|
return json({ targets: rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST: create | { action:'test', id } | { action:'backup_all' }
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
if (!has('delivery')) return moduleOff('delivery');
|
||||||
|
const b = await request.json();
|
||||||
|
if (b.action === 'test') {
|
||||||
|
const cfg = await loadTargetConfig(b.id);
|
||||||
|
if (!cfg) return json({ ok: false, message: 'Target is not fully configured.' });
|
||||||
|
return json(await testConnection(cfg));
|
||||||
|
}
|
||||||
|
if (b.action === 'backup_all') {
|
||||||
|
const { backupAll } = await import('../../../lib/backup');
|
||||||
|
return json(await backupAll());
|
||||||
|
}
|
||||||
|
if (!b.name?.trim()) return json({ error: 'Name is missing.' }, 400);
|
||||||
|
const row = await one<any>(
|
||||||
|
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
|
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
|
||||||
|
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]);
|
||||||
|
return json({ id: row!.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
// PATCH: edit
|
||||||
|
export const PATCH: APIRoute = async ({ request }) => {
|
||||||
|
if (!has('delivery')) return moduleOff('delivery');
|
||||||
|
const b = await request.json();
|
||||||
|
if (!b.id) return json({ error: 'No id.' }, 400);
|
||||||
|
const sets: string[] = []; const args: any[] = [];
|
||||||
|
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||||
|
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
|
||||||
|
if (c in b) set(c, b[c] === '' ? null : b[c]);
|
||||||
|
}
|
||||||
|
if ('is_backup' in b) set('is_backup', !!b.is_backup);
|
||||||
|
if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar);
|
||||||
|
if (b.password) set('password_enc', encrypt(String(b.password)));
|
||||||
|
if (!sets.length) return json({ ok: true });
|
||||||
|
args.push(b.id);
|
||||||
|
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ request, url }) => {
|
||||||
|
if (!has('delivery')) return moduleOff('delivery');
|
||||||
|
let id = url.searchParams.get('id');
|
||||||
|
if (!id) { try { id = (await request.json())?.id; } catch { /* no matter */ } }
|
||||||
|
if (!id) return json({ error: 'No id.' }, 400);
|
||||||
|
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { one, query } from '../../../lib/db';
|
||||||
|
import { listImageModels } from '../../../lib/openrouter';
|
||||||
|
import { has, moduleOff } from '../../../lib/modules';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
// GET: the stored models; ?available=1 adds the ones OpenRouter offers.
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
const models = await query('SELECT * FROM models ORDER BY sort, label');
|
||||||
|
const out: any = { models };
|
||||||
|
if (url.searchParams.get('available') === '1') {
|
||||||
|
try {
|
||||||
|
const avail = await listImageModels();
|
||||||
|
out.available = avail.map((m: any) => ({
|
||||||
|
model_id: m.id, name: m.name,
|
||||||
|
supports_alpha: (m.supported_parameters?.background?.values || []).includes('transparent')
|
||||||
|
|| !!m.supported_parameters?.background,
|
||||||
|
}));
|
||||||
|
} catch (e: any) { out.available_error = e?.friendly || 'Could not fetch the model list.'; }
|
||||||
|
}
|
||||||
|
return json(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST: create/update a model (upsert by model_id) or run an action (toggle/default/delete).
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
const b = await request.json();
|
||||||
|
if (b.action === 'delete' && b.id) { await query('DELETE FROM models WHERE id=$1', [b.id]); return json({ ok: true }); }
|
||||||
|
if (b.action === 'default' && b.id) {
|
||||||
|
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (b.action === 'nsfw' && b.id) {
|
||||||
|
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (!b.model_id || !b.label) return json({ error: 'model_id and label are required.' }, 400);
|
||||||
|
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
|
||||||
|
if (existing) {
|
||||||
|
await query(`UPDATE models SET label=$2, description=$3, active=$4, supports_alpha=$5, sort=$6 WHERE id=$1`,
|
||||||
|
[existing.id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
|
||||||
|
return json({ id: existing.id });
|
||||||
|
}
|
||||||
|
const row = await one(`INSERT INTO models (model_id, label, description, active, supports_alpha, sort)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||||
|
[b.model_id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
|
||||||
|
return json({ model: row });
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import net from 'node:net';
|
||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import { one } from '../../../lib/db';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
/** TCP connection test from inside the container (with a timeout). */
|
||||||
|
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const sock = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
const finish = (ok: boolean, error?: string) => {
|
||||||
|
if (done) return; done = true;
|
||||||
|
try { sock.destroy(); } catch { /* no matter */ }
|
||||||
|
resolve({ ok, ms: Date.now() - start, error });
|
||||||
|
};
|
||||||
|
sock.setTimeout(timeoutMs);
|
||||||
|
sock.once('connect', () => finish(true));
|
||||||
|
sock.once('timeout', () => finish(false, 'Timeout — no answer (firewall or routing?)'));
|
||||||
|
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Connection error'));
|
||||||
|
sock.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether a host:port can be reached from the container. Body {host,port}, or
|
||||||
|
* the configured backup mirror host if nothing is given. Also resolves DNS. */
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
|
let host = (b.host || '').trim();
|
||||||
|
let port = Number(b.port) || 0;
|
||||||
|
if (!host) {
|
||||||
|
const s = await one<{ mirror_host: string | null; mirror_port: number | null; mirror_protocol: string | null }>(
|
||||||
|
'SELECT mirror_host, mirror_port, mirror_protocol FROM settings WHERE id=1');
|
||||||
|
host = (s?.mirror_host || '').trim();
|
||||||
|
port = s?.mirror_port || (s?.mirror_protocol === 'ftps' ? 21 : 22);
|
||||||
|
}
|
||||||
|
if (!host) return json({ error: 'No host given.' }, 400);
|
||||||
|
if (!port) port = 22;
|
||||||
|
|
||||||
|
const out: any = { host, port };
|
||||||
|
// DNS: only if this is not a plain IP.
|
||||||
|
const isIp = net.isIP(host) !== 0;
|
||||||
|
if (!isIp) {
|
||||||
|
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
|
||||||
|
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS lookup failed' }; }
|
||||||
|
} else {
|
||||||
|
out.resolve = { ok: true, address: host, note: 'IP address' };
|
||||||
|
}
|
||||||
|
out.tcp = await tcpProbe(host, port);
|
||||||
|
out.reachable = out.tcp.ok;
|
||||||
|
return json(out);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query } from '../../../lib/db';
|
||||||
|
import { deleteObject } from '../../../lib/storage';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
/** Resets the content: deletes all images, jobs and folders plus their files.
|
||||||
|
* Users, settings, models and preset recipes are kept.
|
||||||
|
* Requires { confirm: 'RESET' }. */
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
|
if (b.confirm !== 'RESET') return json({ error: 'Confirmation is missing (confirm=RESET).' }, 400);
|
||||||
|
|
||||||
|
// Remove every object from storage first.
|
||||||
|
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
|
||||||
|
let objects = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||||
|
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||||
|
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty the content tables (the configuration stays).
|
||||||
|
await query('DELETE FROM print_job_items');
|
||||||
|
await query('DELETE FROM print_jobs');
|
||||||
|
await query('DELETE FROM share_links');
|
||||||
|
await query('DELETE FROM telegram_drafts');
|
||||||
|
await query('DELETE FROM items');
|
||||||
|
await query('DELETE FROM jobs');
|
||||||
|
await query('DELETE FROM folders');
|
||||||
|
|
||||||
|
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { one, query } from '../../../lib/db';
|
||||||
|
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
|
||||||
|
import { loadMailSource } from '../../../lib/mail';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const s = await one<any>('SELECT * FROM settings WHERE id=1');
|
||||||
|
const mail = await loadMailSource();
|
||||||
|
if (!s) return json({ settings: null });
|
||||||
|
let orMask = '';
|
||||||
|
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'set'; } }
|
||||||
|
return json({ settings: {
|
||||||
|
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
|
||||||
|
delivery_host: s.delivery_host, delivery_protocol: s.delivery_protocol, delivery_port: s.delivery_port,
|
||||||
|
delivery_user: s.delivery_user, delivery_base_path: s.delivery_base_path,
|
||||||
|
delivery_default_folder: s.delivery_default_folder,
|
||||||
|
delivery_password_set: !!s.delivery_password_enc,
|
||||||
|
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||||
|
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||||
|
// Storage housekeeping
|
||||||
|
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
|
||||||
|
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
||||||
|
delivery_metadata_sidecar: s.delivery_metadata_sidecar, mirror_metadata_sidecar: s.mirror_metadata_sidecar,
|
||||||
|
output_ext: s.output_ext,
|
||||||
|
// Permissions / privacy / NSFW
|
||||||
|
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
|
||||||
|
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
|
||||||
|
// Backup mirror
|
||||||
|
mirror_enabled: s.mirror_enabled, mirror_host: s.mirror_host, mirror_protocol: s.mirror_protocol, mirror_port: s.mirror_port,
|
||||||
|
mirror_user: s.mirror_user, mirror_base_path: s.mirror_base_path, mirror_password_set: !!s.mirror_password_enc,
|
||||||
|
// Outgoing mail (the password is never returned, only whether it is set)
|
||||||
|
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
|
||||||
|
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
|
||||||
|
// Where the credentials in effect come from: 'database' | 'environment' | null
|
||||||
|
smtp_source: mail?.source ?? null,
|
||||||
|
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
||||||
|
// Sharing
|
||||||
|
share_default_days: s.share_default_days,
|
||||||
|
} });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: APIRoute = async ({ request }) => {
|
||||||
|
const b = await request.json();
|
||||||
|
const sets: string[] = []; const args: any[] = [];
|
||||||
|
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
|
||||||
|
|
||||||
|
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
|
||||||
|
if (b.delivery_password) set('delivery_password_enc', encrypt(String(b.delivery_password)));
|
||||||
|
if (b.mirror_password) set('mirror_password_enc', encrypt(String(b.mirror_password)));
|
||||||
|
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
|
||||||
|
if (b.generate_api_token) set('api_token', randomToken());
|
||||||
|
const boolCols = ['keep_sources', 'make_thumbnails', 'mirror_enabled', 'metadata_sidecar',
|
||||||
|
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
|
'smtp_secure'];
|
||||||
|
for (const col of ['delivery_host', 'delivery_protocol', 'delivery_port', 'delivery_user', 'delivery_base_path',
|
||||||
|
'delivery_default_folder', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||||
|
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||||
|
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
||||||
|
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
|
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
||||||
|
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
|
||||||
|
if (col in b) {
|
||||||
|
const raw = b[col];
|
||||||
|
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||||
|
set(col, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sets.length) return json({ ok: true });
|
||||||
|
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query, one } from '../../../lib/db';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`);
|
||||||
|
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
|
||||||
|
const cost = async (days: number | null) => (await one<{ c: string }>(
|
||||||
|
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
|
||||||
|
(days ? ` AND created_at > now() - interval '${days} days'` : ''))
|
||||||
|
)?.c || '0';
|
||||||
|
const byModel = await query(`SELECT COALESCE(model_used,'—') m, count(*)::int n, COALESCE(sum(cost),0)::float c
|
||||||
|
FROM items WHERE status='done' GROUP BY 1 ORDER BY n DESC`);
|
||||||
|
const last = await one<{ t: string }>(`SELECT max(created_at)::text t FROM items`);
|
||||||
|
|
||||||
|
const totalN = Number(total?.n || 0), failedN = Number(failed?.n || 0);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
total: totalN, failed: failedN,
|
||||||
|
error_rate: totalN + failedN ? Math.round((failedN / (totalN + failedN)) * 100) : 0,
|
||||||
|
cost_today: Number(await cost(1)), cost_7: Number(await cost(7)),
|
||||||
|
cost_30: Number(await cost(30)), cost_all: Number(await cost(null)),
|
||||||
|
by_model: byModel, last_activity: last?.t || null,
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { storageStats, runRetention, purgeSources, mirrorAllToBackup, rebuildThumbs } from '../../../lib/maintenance';
|
||||||
|
import { renameLibrary, redeliverAll } from '../../../lib/renames';
|
||||||
|
import { one } from '../../../lib/db';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => json({ stats: await storageStats() });
|
||||||
|
|
||||||
|
// POST { action: 'retention' | 'purge_sources' | 'rename_preview' | 'rename_apply' | 'redeliver_all' | … }
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const b = await request.json().catch(() => ({}));
|
||||||
|
if (b.action === 'purge_sources') return json(await purgeSources());
|
||||||
|
if (b.action === 'mirror_all') return json(await mirrorAllToBackup());
|
||||||
|
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
|
||||||
|
// Unique file names: first show what would happen, then apply it.
|
||||||
|
if (b.action === 'rename_preview') return json(await renameLibrary(true));
|
||||||
|
if (b.action === 'rename_apply') return json(await renameLibrary(false));
|
||||||
|
if (b.action === 'redeliver_all') return json(await redeliverAll());
|
||||||
|
if (b.action === 'retention') {
|
||||||
|
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||||
|
if (!s?.retention_days) return json({ error: 'No retention period is set.' }, 400);
|
||||||
|
return json(await runRetention(s.retention_days));
|
||||||
|
}
|
||||||
|
return json({ error: 'Unknown action.' }, 400);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query } from '../../../../lib/db';
|
||||||
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
if (!has('telegram')) return moduleOff('telegram');
|
||||||
|
const links = await query(`SELECT l.chat_id, l.active, l.linked_at, u.display_name AS user_name
|
||||||
|
FROM telegram_links l LEFT JOIN users u ON u.id=l.user_id ORDER BY l.linked_at DESC`);
|
||||||
|
return json({ links });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ request }) => {
|
||||||
|
if (!has('telegram')) return moduleOff('telegram');
|
||||||
|
const b = await request.json().catch(() => ({}));
|
||||||
|
if (!b.chat_id) return json({ error: 'chat_id is required.' }, 400);
|
||||||
|
await query('DELETE FROM telegram_links WHERE chat_id=$1', [b.chat_id]);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { one } from '../../../../lib/db';
|
||||||
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
// Creates a six-digit pairing code (valid for 15 minutes) for a user.
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
if (!has('telegram')) return moduleOff('telegram');
|
||||||
|
const b = await request.json().catch(() => ({}));
|
||||||
|
const userId = b.userId || locals.user?.uid;
|
||||||
|
if (!userId) return json({ error: 'userId is required.' }, 400);
|
||||||
|
// Six-digit code (Math.random is fine here — no cryptographic secret, and short-lived)
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
await one(`INSERT INTO telegram_pairing_codes (code, user_id, expires_at)
|
||||||
|
VALUES ($1,$2, now() + interval '15 minutes')
|
||||||
|
ON CONFLICT (code) DO UPDATE SET user_id=$2, expires_at=now()+interval '15 minutes', used_at=NULL`,
|
||||||
|
[code, userId]);
|
||||||
|
return json({ code, expires_minutes: 15 });
|
||||||
|
};
|
||||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user