diff --git a/README.md b/README.md
index 4f3c1d5..32a6d5d 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
+- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
+ 10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
ablegen, um sie wiederzufinden.
@@ -353,6 +355,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
404 — über einen gefälschten Header lässt sich also keine fremde Domain
ansprechen.
+### Nachprüfen, ob eine Domain wirklich hierher führt
+
+Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
+Weg der Scanner nach und meldet vier Dinge:
+
+| Schritt | Was geprüft wird |
+| --- | --- |
+| DNS | Löst der Name auf? Auf welche Adressen? |
+| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
+| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
+| Ziel | Steht dahinter **diese** Installation? |
+
+Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
+Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
+Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
+verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
+irgendwohin“ von „die Domain zeigt hierher“.
+
+> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
+> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
+> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
+> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
+> Dafür bleibt der Blick von einem fremden Netz aus.
+
Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
@@ -638,13 +664,18 @@ Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
-- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
+- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
+- ein **Filter nach Art**, sobald dynamische und statische Codes
+ nebeneinander liegen.
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht.
-Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
+Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
+Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
+Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
+Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
diff --git a/docs/funktionen.md b/docs/funktionen.md
index b20af91..88387e5 100644
--- a/docs/funktionen.md
+++ b/docs/funktionen.md
@@ -122,6 +122,40 @@ Liste steht dort „statisch“ und ein Strich.
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
+**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
+identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
+wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
+keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
+
+Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
+Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
+genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
+bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
+sobald zwei Kampagnen getrennt gezählt werden sollen.
+
+### Ein Logo in der Mitte
+
+Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
+Zur Wahl stehen zwei Quellen:
+
+- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
+ genügt, kein Upload je Code;
+- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
+
+Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
+sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
+Fläche in der Hintergrundfarbe, damit es sich absetzt.
+
+**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
+muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
+heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
+Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
+nach Ausrichtungsmustern.
+
+Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
+Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
+Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
+
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
diff --git a/docs/sicherheit.md b/docs/sicherheit.md
index 62aa2b5..366ab25 100644
--- a/docs/sicherheit.md
+++ b/docs/sicherheit.md
@@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
---
+## Der Domain-Check und seine Grenzen
+
+Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
+Server aus auf. Drei Dinge grenzen das ein:
+
+- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
+ aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
+ aus beliebige Adressen abklopfen ließen.
+- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
+ die auch Domains anlegt.
+- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
+ Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
+ preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
+ das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
+ Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
+
+Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
+Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
+selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
+Übertragen wird über diese Verbindung nichts.
+
+---
+
## Offen
Bewusst nicht umgesetzt, mit Begründung.
diff --git a/package-lock.json b/package-lock.json
index 039f195..c4ba013 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "qrcode-generator",
- "version": "1.1.0",
+ "version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qrcode-generator",
- "version": "1.1.0",
+ "version": "1.6.0",
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
@@ -17,6 +17,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
+ "sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -566,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -2065,6 +2065,72 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4039,7 +4105,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7795,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -7845,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/package.json b/package.json
index 7423cb9..e840a46 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
+ "sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/prisma/migrations/20260821010000_domain_check/migration.sql b/prisma/migrations/20260821010000_domain_check/migration.sql
new file mode 100644
index 0000000..9645111
--- /dev/null
+++ b/prisma/migrations/20260821010000_domain_check/migration.sql
@@ -0,0 +1,7 @@
+-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
+-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
+-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
+--
+-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
+-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
+ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
diff --git a/prisma/migrations/20260821020000_logo_im_code/migration.sql b/prisma/migrations/20260821020000_logo_im_code/migration.sql
new file mode 100644
index 0000000..53b87ff
--- /dev/null
+++ b/prisma/migrations/20260821020000_logo_im_code/migration.sql
@@ -0,0 +1,11 @@
+-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
+--
+-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
+-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
+-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
+ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
+ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
+
+-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
+-- Wert tragen. Ohne Logo bleibt er wirkungslos.
+ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4c5ef39..23c6f4e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -208,6 +208,14 @@ model QrCode {
size Int @default(512)
ecLevel String @default("M")
margin Int @default(4)
+ /// Logo in der Mitte: "branding" nimmt das Logo der Installation,
+ /// "upload" eine eigene Datei. Leer heisst: kein Logo.
+ logoSource String?
+ /// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
+ /// `Upload.qrCodeId` am Code und verschwindet mit ihm.
+ logoUploadId String?
+ /// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
+ logoScale Int @default(20)
scans Int @default(0)
lastScanAt DateTime?
createdAt DateTime @default(now())
@@ -288,6 +296,14 @@ model InstanceSetting {
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
+ /// Geheimnis fuer den Domain-Check.
+ ///
+ /// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
+ /// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
+ /// kann ihn bilden — so steht fest, dass wirklich diese Installation
+ /// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
+ /// Server nie und entsteht beim ersten Check.
+ checkSecret String?
updatedAt DateTime @updatedAt
}
diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx
index 2710a0e..9ee68df 100644
--- a/src/app/(app)/erstellen/[type]/page.tsx
+++ b/src/app/(app)/erstellen/[type]/page.tsx
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
+import { getBranding } from "@/lib/branding";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
@@ -96,5 +97,15 @@ export default async function CreatePage({ params }: Props) {
editorsMaySetSlug,
});
- return
- Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der - Scans. Statische Codes erscheinen hier nicht — sie werden nicht - gespeichert. + Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans, + dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“ + lässt sich die Liste auf eine der beiden Arten einschränken.
diff --git a/src/app/api/admin/domains/[id]/check/route.ts b/src/app/api/admin/domains/[id]/check/route.ts new file mode 100644 index 0000000..950af1e --- /dev/null +++ b/src/app/api/admin/domains/[id]/check/route.ts @@ -0,0 +1,43 @@ +import { getActor } from "@/lib/auth/actor"; +import { requirePermission } from "@/lib/auth/policy"; +import { prisma } from "@/lib/db"; +import { pruefeDomain } from "@/lib/domain-check"; +import { errorResponse } from "@/lib/http"; +import { QrRequestError } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +/** + * Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob + * dahinter wirklich diese Installation steht. + * + * `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung + * nach außen an und ist nichts, was sich vorsorglich abrufen lässt. + * + * Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname + * aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom + * Server aus beliebige Adressen abklopfen lassen. + */ +export async function POST( + _request: Request, + context: Context, +): Promise- Kein Code passt zur Suche.{" "} + Kein Code passt zu Suche und Filtern.{" "}