Klarbild 1.0.0 — first public release
CI / check (push) Has been cancelled

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:
tillandClaude Opus 5 committed 2026-09-15 14:52:41 +00:00
commit e2481139a1
164 files changed
+27190

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.git
node_modules
dist
.astro
*.log
.env
# Do NOT ignore migrations/ — they are read at runtime, on every boot.
+119
View File
@@ -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=
+34
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
.env
.env.*
!.env.example
node_modules
dist
.astro
*.log
uploads/
data/
+35
View File
@@ -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.
+79
View File
@@ -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
View File
@@ -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"]
+662
View File
@@ -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/>.
+157
View File
@@ -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
```
![Klarbild's print studio: eight passport photos laid out on one A4 sheet with corner marks](docs/screenshots/print-studio.png)
---
## 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
View File
@@ -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.
+38
View File
@@ -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'],
},
},
});
+104
View File
@@ -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:
+77
View File
@@ -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

+52
View File
@@ -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).
+188
View File
@@ -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');
+168
View File
@@ -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
);
+2
View File
@@ -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;
+2
View File
@@ -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'));
+5
View File
@@ -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'));
+4
View File
@@ -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);
+21
View File
@@ -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)
);
+18
View File
@@ -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;
+4
View File
@@ -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);
+4
View File
@@ -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'));
+14
View File
@@ -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'));
+71
View File
@@ -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);
+37
View File
@@ -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;
+39
View File
@@ -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);
+9266
View File
File diff suppressed because it is too large. Load diff
+71
View File
@@ -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"
}
}
+85
View File
@@ -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.');
+164
View File
@@ -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>
);
}
+672
View File
@@ -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 &lt;mail@your-domain.com&gt;" 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>;
}
+364
View File
@@ -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
+227
View File
@@ -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>;
}
+170
View File
@@ -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>;
}
+315
View File
@@ -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>
);
}
+265
View File
@@ -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>
);
}
+547
View File
@@ -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>;
}
+9
View File
@@ -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;
}
}
+175
View File
@@ -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
View File
@@ -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); }
+71
View File
@@ -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 };
}
+90
View File
@@ -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 }));
}
+37
View File
@@ -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}`;
}
+56
View File
@@ -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();
}
}
}
+85
View File
@@ -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);
}
+77
View File
@@ -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;
}
+18
View File
@@ -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));
}
+70
View File
@@ -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';
+33
View File
@@ -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;
}
+71
View File
@@ -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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/**
* 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);
};
+149
View File
@@ -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
}
+39
View File
@@ -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`;
}
+94
View File
@@ -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.' };
}
}
+99
View File
@@ -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' },
});
}
+247
View File
@@ -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`;
}
+101
View File
@@ -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 ?? [];
}
+201
View File
@@ -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);
+84
View File
@@ -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);
}
+392
View File
@@ -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 };
}
+126
View File
@@ -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,
};
}
+258
View File
@@ -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();
}
+194
View File
@@ -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;
}
+56
View File
@@ -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.
+230
View File
@@ -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 };
}
}
+79
View File
@@ -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.`;
}
+201
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** 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');
}
+34
View File
@@ -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}).`);
}
+126
View File
@@ -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;
}
+103
View File
@@ -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
View File
@@ -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 }],
});
}
+371
View File
@@ -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;
}
+95
View File
@@ -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`;
}
+507
View File
@@ -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);
}
});
}
+40
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+94
View File
@@ -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;
}
+24
View File
@@ -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>
+14
View File
@@ -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>
+45
View File
@@ -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.' });
}
};
+66
View File
@@ -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 });
};
+52
View File
@@ -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 });
};
+56
View File
@@ -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);
};
+36
View File
@@ -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 });
};
+77
View File
@@ -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 });
};
+25
View File
@@ -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' } });
};
+28
View File
@@ -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);
};
+22
View File
@@ -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