Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e173c3bf7b | ||
|
|
cfd0f0747a |
+66
-5
@@ -63,8 +63,6 @@ server/src/routes/dash.ts die API fürs Dashboard
|
|||||||
server/src/index.ts Host-Weiche, Dateiauslieferung, Start
|
server/src/index.ts Host-Weiche, Dateiauslieferung, Start
|
||||||
```
|
```
|
||||||
|
|
||||||
Typprüfung läuft durch. **Noch nicht gestartet und nicht getestet.**
|
|
||||||
|
|
||||||
Die API für Skripte:
|
Die API für Skripte:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -86,10 +84,73 @@ Die API fürs Dashboard: Anmeldung, Passwortwechsel, Medienliste mit Suche und
|
|||||||
Paginierung, Einzel- und Sammellöschung, Statistik nach Ordnern,
|
Paginierung, Einzel- und Sammellöschung, Statistik nach Ordnern,
|
||||||
Token-Verwaltung.
|
Token-Verwaltung.
|
||||||
|
|
||||||
### ⬜ Als Nächstes
|
### ✅ Fertig — Schritt 1, der Smoke-Test
|
||||||
|
|
||||||
**1 · Smoke-Test.** Server lokal starten, Token anlegen, ein Bild hoch- und
|
Gestartet, durchgemessen, repariert. Der Durchstich steht: anmelden → Token
|
||||||
wieder abrufen. Vor Docker, vor allem anderen.
|
anlegen → Bild hochladen → unter der zurückgegebenen URL abrufen, Byte für Byte
|
||||||
|
identisch, auch unter dem Dateihost.
|
||||||
|
|
||||||
|
**So läuft er lokal:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix server ci && npm --prefix server run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Der Stolperstein beim lokalen Start:** ohne `FILES_HOST` nimmt der Dienst
|
||||||
|
> den Host aus `PUBLIC_URL` — lokal also `localhost`. Dann gilt *jede* Anfrage
|
||||||
|
> als Anfrage an den Dateiwirt, und dort gibt es absichtlich weder Dashboard
|
||||||
|
> noch API: `/health` antwortet 404, die Anmeldung 405 „hier gibt es nur
|
||||||
|
> Dateien". Nichts ist kaputt. In Produktion sind es zwei verschiedene Namen
|
||||||
|
> und der Standard stimmt; lokal gehört ein Name hin, den niemand aufruft.
|
||||||
|
> Steht samt Begründung in `server/.env.example`.
|
||||||
|
|
||||||
|
**Was der Test gefunden hat** — alles im Grundgerüst, alles repariert:
|
||||||
|
|
||||||
|
1. **Die Token-Prüfung hing an `'*'`** und galt damit auch für `/api/dash/*`,
|
||||||
|
das daneben liegt. Das Dashboard bekam „Token fehlt oder ist unbekannt" auf
|
||||||
|
die Anmeldung, obwohl es nie einen Token haben kann. Jetzt hängt sie an den
|
||||||
|
drei eigenen Pfaden.
|
||||||
|
2. **`DELETE /api/media/…` und `GET /api/exists/…` sahen am Ziel vorbei.**
|
||||||
|
`c.req.path` trägt den Einhängepunkt mit, das `replace(/^\/media\//)` schnitt
|
||||||
|
ihn nicht weg — aus `vehicles/adder.png` wurde `api/media/vehicles/adder.png`.
|
||||||
|
Löschen fand nie etwas, `exists` meldete immer `false`. Jetzt `:pfad{.+}`;
|
||||||
|
Hono liefert den Parameter fertig dekodiert (nachgemessen), ein zweites
|
||||||
|
`decodeURIComponent` wäre eine Dekodierung zu viel gewesen.
|
||||||
|
3. **Verzeichnisdurchstieg in der SPA-Rückfallroute.**
|
||||||
|
`GET /..%5Cpackage.json` hat unter Windows die Datei ausgeliefert: Hono
|
||||||
|
reicht `%5C` durch, `path.join` behandelt den Backslash dort als Trenner,
|
||||||
|
und eine Eindämmung gab es nicht. Unter Linux trägt genau dieser Angriff
|
||||||
|
nicht — Glück, keine Abwehr. Jetzt dieselbe `resolve`-Prüfung wie in
|
||||||
|
`storage.ts`.
|
||||||
|
4. **Die Auskunft „Oberfläche ist nicht gebaut" war unerreichbar.**
|
||||||
|
`createReadStream` meldet eine fehlende Datei *asynchron*; das `try/catch`
|
||||||
|
darum fing nichts. Ergebnis war ein leerer 200 samt ENOENT im Log. Jetzt
|
||||||
|
wird vorher nachgesehen.
|
||||||
|
5. **CSS und JS kamen als `application/octet-stream`** — die MIME-Tabelle kennt
|
||||||
|
nur Medientypen. Das Dashboard hätte weder Stylesheet noch Modul geladen.
|
||||||
|
Die Oberfläche hat jetzt eine **eigene** Tabelle: in der geteilten fehlt
|
||||||
|
`html` mit Absicht, sonst könnte jeder mit einem Upload-Token eine Seite
|
||||||
|
unter `fivecdn.d4rkst3r.de` veröffentlichen. Nachgemessen: eine hochgeladene
|
||||||
|
`.html` kommt weiterhin als `octet-stream` mit `nosniff`.
|
||||||
|
6. **Kaputtes JSON endete als nackter „Internal Server Error".** Ausgerechnet
|
||||||
|
das — ein 500 ohne ein Wort dazu ist der Fehler, wegen dem wir hier neu
|
||||||
|
bauen. Jetzt 400 mit Text.
|
||||||
|
|
||||||
|
**Was sonst nachgemessen wurde und trägt:** alle drei Rumpfformen (multipart,
|
||||||
|
Base64, rohe Bytes) liefern denselben SHA-256, auch mit `data:`-Vorspann · der
|
||||||
|
Hash-Pfad ohne `X-Path` · Präfix-Fessel und `can_delete` je Token · `..`,
|
||||||
|
versteckte Dateien und leerer Rumpf werden abgelehnt · `X-Overwrite: false`
|
||||||
|
gibt 409, Überschreiben `replaced: true` · 413 vor dem Einlesen · ETag/304,
|
||||||
|
HEAD, Bereichsanfragen und 416 · unter dem Dateihost gibt es weder API noch
|
||||||
|
`/f/`-Präfix · Medienliste, Suche, Statistik nach Ordnern.
|
||||||
|
|
||||||
|
Nicht getestet: Docker, das Dashboard (gibt es noch nicht), und echte Last.
|
||||||
|
|
||||||
|
### ⬜ Als Nächstes
|
||||||
|
|
||||||
**2 · Dockerfile und Compose.** Ein Abbild, das Server und gebaute Oberfläche
|
**2 · Dockerfile und Compose.** Ein Abbild, das Server und gebaute Oberfläche
|
||||||
ausliefert; ein Volume für `data/`; ein Port für NPM. In Portainer aus diesem
|
ausliefert; ein Volume für `data/`; ein Port für NPM. In Portainer aus diesem
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Kopieren nach server/.env, dann `npm run dev`.
|
||||||
|
#
|
||||||
|
# Ohne PUBLIC_URL und ADMIN_PASSWORD startet der Dienst nicht — das ist
|
||||||
|
# Absicht, siehe config.ts.
|
||||||
|
|
||||||
|
# Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL
|
||||||
|
# gebaut, die nach dem Upload zurueckkommt. Ohne abschliessenden Schraegstrich.
|
||||||
|
PUBLIC_URL=http://localhost:8080/f
|
||||||
|
|
||||||
|
# DER STOLPERSTEIN BEIM LOKALEN START.
|
||||||
|
#
|
||||||
|
# Ohne diese Zeile nimmt der Dienst den Host aus PUBLIC_URL — lokal also
|
||||||
|
# "localhost". Dann gilt jede Anfrage als Anfrage an den Dateiwirt, und dort
|
||||||
|
# gibt es absichtlich weder Dashboard noch API: /health antwortet 404, die
|
||||||
|
# Anmeldung 405 "hier gibt es nur Dateien". Nichts ist kaputt, der Dienst tut
|
||||||
|
# genau das, was er soll — nur eben unter dem einen Namen, den man lokal hat.
|
||||||
|
#
|
||||||
|
# In Produktion sind es zwei verschiedene Namen und der Standard stimmt:
|
||||||
|
# PUBLIC_URL=https://fivecdn.d4rkst3r.de
|
||||||
|
# FILES_HOST=fivecdn.d4rkst3r.de (Dashboard: fivemanage.d4rkst3r.de)
|
||||||
|
#
|
||||||
|
# Lokal deshalb ein Name, den niemand aufruft — dann bleibt localhost fuer
|
||||||
|
# Dashboard und API frei. Zum Testen des Dateiwirts:
|
||||||
|
# curl -H "Host: files.localhost" http://localhost:8080/vehicles/adder.webp
|
||||||
|
FILES_HOST=files.localhost
|
||||||
|
|
||||||
|
# Wird beim ERSTEN Start angelegt. Ein spaeter geaendertes Passwort setzt das
|
||||||
|
# bestehende nicht zurueck.
|
||||||
|
ADMIN_PASSWORD=bitte-aendern
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Ein Ordner fuer Bilder UND media.db — eine Sicherung ist damit ein cp -a.
|
||||||
|
# Relativ zum Arbeitsverzeichnis; im Abbild spaeter /data.
|
||||||
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
# Groesste erlaubte Datei in MB. Ein freigestelltes Fahrzeugbild wiegt rund
|
||||||
|
# 200 KB.
|
||||||
|
MAX_UPLOAD_MB=64
|
||||||
|
|
||||||
|
# Wie lange eine Anmeldung im Dashboard haelt.
|
||||||
|
SESSION_DAYS=30
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+86
-23
@@ -18,7 +18,7 @@
|
|||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { stat } from 'node:fs/promises'
|
import { stat } from 'node:fs/promises'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { join } from 'node:path'
|
import { join, resolve, sep } from 'node:path'
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { logger } from 'hono/logger'
|
import { logger } from 'hono/logger'
|
||||||
@@ -120,38 +120,101 @@ app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
|||||||
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
|
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
|
||||||
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
|
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
|
||||||
// "/C:/..." und jedes stat() darauf schlaegt fehl.
|
// "/C:/..." und jedes stat() darauf schlaegt fehl.
|
||||||
const webDir = fileURLToPath(new URL('../web/', import.meta.url))
|
const webDir = resolve(fileURLToPath(new URL('../web/', import.meta.url)))
|
||||||
|
|
||||||
|
/** Der absolute Pfad zu einer Datei der Oberflaeche — oder null, wenn er aus
|
||||||
|
* dem Ordner herauszeigt.
|
||||||
|
*
|
||||||
|
* Dieselbe Guertel-und-Hosentraeger-Regel wie in storage.ts, und aus demselben
|
||||||
|
* Grund: der Pfad kommt vom Aufrufer. Ohne die Pruefung hat
|
||||||
|
* `GET /..%5Cpackage.json` unter Windows die Datei ausgeliefert — Hono reicht
|
||||||
|
* %5C unveraendert durch, und path.join behandelt den Backslash dort als
|
||||||
|
* Trenner. Unter Linux traegt derselbe Angriff nicht, aber das ist Glueck und
|
||||||
|
* keine Abwehr. */
|
||||||
|
function webFile(candidate: string): string | null {
|
||||||
|
const target = resolve(webDir, decodeSafely(candidate).replace(/^[/\\]+/, ''))
|
||||||
|
if (target !== webDir && !target.startsWith(webDir + sep)) return null
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** %5C und Konsorten aufloesen, bevor geprueft wird — sonst prueft die
|
||||||
|
* Eindaemmung eine andere Zeichenkette als die, die spaeter im Dateisystem
|
||||||
|
* landet. Ein kaputtes Prozentzeichen ist kein Grund abzustuerzen. */
|
||||||
|
function decodeSafely(raw: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Dateitypen der Oberflaeche — bewusst eine EIGENE Tabelle und nicht die
|
||||||
|
* aus storage.ts.
|
||||||
|
*
|
||||||
|
* Dort fehlen html, css und js mit gutem Grund: eine hochgeladene .html wird
|
||||||
|
* unter dem Dateihost als application/octet-stream ausgeliefert und damit
|
||||||
|
* heruntergeladen statt ausgefuehrt. Stuende html in der geteilten Tabelle,
|
||||||
|
* koennte jeder mit einem Upload-Token eine Seite unter fivecdn.d4rkst3r.de
|
||||||
|
* veroeffentlichen. Die gebaute Oberflaeche liegt dagegen im Abbild und kommt
|
||||||
|
* von uns — hier ist der richtige Typ noetig, sonst laedt der Browser weder
|
||||||
|
* Stylesheet noch Modul. */
|
||||||
|
const WEB_MIME: Record<string, string> = {
|
||||||
|
html: 'text/html; charset=utf-8',
|
||||||
|
css: 'text/css; charset=utf-8',
|
||||||
|
js: 'text/javascript; charset=utf-8',
|
||||||
|
mjs: 'text/javascript; charset=utf-8',
|
||||||
|
map: 'application/json; charset=utf-8',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
woff: 'font/woff',
|
||||||
|
woff2: 'font/woff2',
|
||||||
|
ttf: 'font/ttf',
|
||||||
|
}
|
||||||
|
|
||||||
|
const webMimeFor = (path: string) =>
|
||||||
|
WEB_MIME[path.split('.').pop()?.toLowerCase() ?? ''] ?? mimeFor(path)
|
||||||
|
|
||||||
app.get('*', async (c) => {
|
app.get('*', async (c) => {
|
||||||
if (c.req.path.startsWith('/api/')) return c.json({ error: 'unbekannt' }, 404)
|
if (c.req.path.startsWith('/api/')) return c.json({ error: 'unbekannt' }, 404)
|
||||||
|
|
||||||
const candidate = c.req.path === '/' ? '/index.html' : c.req.path
|
const candidate = c.req.path === '/' ? '/index.html' : c.req.path
|
||||||
|
const target = webFile(candidate)
|
||||||
|
if (target) {
|
||||||
|
try {
|
||||||
|
const info = await stat(target)
|
||||||
|
if (info.isFile()) {
|
||||||
|
return c.body(createReadStream(target) as any, 200, {
|
||||||
|
'Content-Type': webMimeFor(candidate),
|
||||||
|
'Cache-Control': candidate.includes('/assets/')
|
||||||
|
? 'public, max-age=31536000, immutable'
|
||||||
|
: 'no-cache',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* faellt unten auf index.html */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst nachsehen, dann streamen. createReadStream meldet eine fehlende
|
||||||
|
// Datei ASYNCHRON ueber ein 'error'-Ereignis — ein try/catch darum faengt
|
||||||
|
// nichts. Vorher stand hier genau das, und das Ergebnis war bei ungebauter
|
||||||
|
// Oberflaeche ein leerer 200 samt ENOENT im Log statt der Auskunft unten.
|
||||||
|
const index = join(webDir, 'index.html')
|
||||||
try {
|
try {
|
||||||
const target = join(webDir, candidate.replace(/^\//, ''))
|
if ((await stat(index)).isFile()) {
|
||||||
const info = await stat(target)
|
return c.body(createReadStream(index) as any, 200, {
|
||||||
if (info.isFile()) {
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
return c.body(createReadStream(target) as any, 200, {
|
'Cache-Control': 'no-cache',
|
||||||
'Content-Type': mimeFor(candidate),
|
|
||||||
'Cache-Control': candidate.includes('/assets/')
|
|
||||||
? 'public, max-age=31536000, immutable'
|
|
||||||
: 'no-cache',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* faellt unten auf index.html */
|
/* faellt auf die Auskunft unten */
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return c.text(
|
||||||
return c.body(createReadStream(join(webDir, 'index.html')) as any, 200, {
|
'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' +
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'die API steht unter /api.',
|
||||||
'Cache-Control': 'no-cache',
|
200,
|
||||||
})
|
)
|
||||||
} catch {
|
|
||||||
return c.text(
|
|
||||||
'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' +
|
|
||||||
'die API steht unter /api.',
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --------------------------------------------------------------------- Start
|
// --------------------------------------------------------------------- Start
|
||||||
|
|||||||
+29
-11
@@ -6,6 +6,7 @@
|
|||||||
// Rueckmeldung wie ein kaputter Knopf aussieht.
|
// Rueckmeldung wie ein kaputter Knopf aussieht.
|
||||||
|
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import type { Context } from 'hono'
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js'
|
import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js'
|
||||||
@@ -25,13 +26,27 @@ type Vars = { user: User }
|
|||||||
|
|
||||||
export const dashRoutes = new Hono<{ Variables: Vars }>()
|
export const dashRoutes = new Hono<{ Variables: Vars }>()
|
||||||
|
|
||||||
|
/** Den JSON-Rumpf lesen — oder null, wenn keiner ankam.
|
||||||
|
*
|
||||||
|
* c.req.json() wirft bei kaputtem JSON, und Hono macht daraus einen nackten
|
||||||
|
* "Internal Server Error" ohne ein Wort dazu. Genau diese Sorte Antwort ist
|
||||||
|
* der Grund, warum wir hier neu bauen. */
|
||||||
|
async function jsonBody<T>(c: Context<any>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await c.req.json<T>()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Anmeldung
|
// ---------------------------------------------------------------- Anmeldung
|
||||||
|
|
||||||
dashRoutes.post('/auth/login', async (c) => {
|
dashRoutes.post('/auth/login', async (c) => {
|
||||||
const { username, password } = await c.req.json<{
|
const body = await jsonBody<{ username?: string; password?: string }>(c)
|
||||||
username?: string
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
password?: string
|
const { username, password } = body
|
||||||
}>()
|
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
|
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
|
||||||
@@ -81,10 +96,9 @@ dashRoutes.use('*', async (c, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dashRoutes.post('/auth/password', async (c) => {
|
dashRoutes.post('/auth/password', async (c) => {
|
||||||
const { current, next: fresh } = await c.req.json<{
|
const body = await jsonBody<{ current?: string; next?: string }>(c)
|
||||||
current?: string
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
next?: string
|
const { current, next: fresh } = body
|
||||||
}>()
|
|
||||||
const user = c.get('user')
|
const user = c.get('user')
|
||||||
|
|
||||||
if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400)
|
if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400)
|
||||||
@@ -145,7 +159,9 @@ dashRoutes.delete('/media/:id', async (c) => {
|
|||||||
|
|
||||||
/** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */
|
/** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */
|
||||||
dashRoutes.post('/media/delete', async (c) => {
|
dashRoutes.post('/media/delete', async (c) => {
|
||||||
const { ids } = await c.req.json<{ ids?: number[] }>()
|
const body = await jsonBody<{ ids?: number[] }>(c)
|
||||||
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
|
const { ids } = body
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
return c.json({ error: 'keine Auswahl' }, 400)
|
return c.json({ error: 'keine Auswahl' }, 400)
|
||||||
}
|
}
|
||||||
@@ -197,11 +213,13 @@ dashRoutes.get('/tokens', (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dashRoutes.post('/tokens', async (c) => {
|
dashRoutes.post('/tokens', async (c) => {
|
||||||
const { name, prefix, canDelete } = await c.req.json<{
|
const body = await jsonBody<{
|
||||||
name?: string
|
name?: string
|
||||||
prefix?: string
|
prefix?: string
|
||||||
canDelete?: boolean
|
canDelete?: boolean
|
||||||
}>()
|
}>(c)
|
||||||
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
|
const { name, prefix, canDelete } = body
|
||||||
|
|
||||||
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
|
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
// dem NUI, ist also schon Base64 — der Umweg ist damit keiner.
|
// dem NUI, ist also schon Base64 — der Umweg ist damit keiner.
|
||||||
|
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import type { Context } from 'hono'
|
import type { Context, Next } from 'hono'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { db, now, type Media, type Token } from '../db.js'
|
import { db, now, type Media, type Token } from '../db.js'
|
||||||
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
@@ -40,14 +40,23 @@ type Vars = { token: Token }
|
|||||||
|
|
||||||
export const uploadRoutes = new Hono<{ Variables: Vars }>()
|
export const uploadRoutes = new Hono<{ Variables: Vars }>()
|
||||||
|
|
||||||
uploadRoutes.use('*', async (c, next) => {
|
// Die Token-Pruefung haengt an den drei eigenen Pfaden und NICHT an '*'.
|
||||||
|
//
|
||||||
|
// Mit '*' galt sie fuer alles unterhalb des Einhaengepunkts — auch fuer
|
||||||
|
// /api/dash/*, das daneben liegt. Das Dashboard bekam dann "Token fehlt oder
|
||||||
|
// ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann.
|
||||||
|
const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||||
const token = tokenFromHeader(c.req.header('authorization'))
|
const token = tokenFromHeader(c.req.header('authorization'))
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
|
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
|
||||||
}
|
}
|
||||||
c.set('token', token)
|
c.set('token', token)
|
||||||
await next()
|
await next()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
uploadRoutes.use('/upload', requireToken)
|
||||||
|
uploadRoutes.use('/media/*', requireToken)
|
||||||
|
uploadRoutes.use('/exists/*', requireToken)
|
||||||
|
|
||||||
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
|
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
|
||||||
async function readBody(
|
async function readBody(
|
||||||
@@ -179,8 +188,14 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. */
|
/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID.
|
||||||
uploadRoutes.delete('/media/*', async (c) => {
|
*
|
||||||
|
* `:pfad{.+}` und nicht `*`: ein Sternchen wird von Hono nicht als Parameter
|
||||||
|
* erfasst, und `c.req.path` traegt den Einhaengepunkt mit — aus
|
||||||
|
* DELETE /api/media/vehicles/adder.png wurde damit der Pfad
|
||||||
|
* "api/media/vehicles/adder.png". Der Parameter kommt bereits dekodiert,
|
||||||
|
* ein zweites decodeURIComponent waere eine Dekodierung zu viel. */
|
||||||
|
uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
|
||||||
const token = c.get('token')
|
const token = c.get('token')
|
||||||
if (!token.can_delete) {
|
if (!token.can_delete) {
|
||||||
return c.json({ error: 'dieser Token darf nicht loeschen' }, 403)
|
return c.json({ error: 'dieser Token darf nicht loeschen' }, 403)
|
||||||
@@ -188,7 +203,7 @@ uploadRoutes.delete('/media/*', async (c) => {
|
|||||||
|
|
||||||
let path: string
|
let path: string
|
||||||
try {
|
try {
|
||||||
path = checkPath(decodeURIComponent(c.req.path.replace(/^\/media\//, '')))
|
path = checkPath(c.req.param('pfad'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||||
throw err
|
throw err
|
||||||
@@ -206,10 +221,10 @@ uploadRoutes.delete('/media/*', async (c) => {
|
|||||||
|
|
||||||
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
|
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
|
||||||
* beantworten kann, ohne 900 Bilder hochzuladen. */
|
* beantworten kann, ohne 900 Bilder hochzuladen. */
|
||||||
uploadRoutes.get('/exists/*', (c) => {
|
uploadRoutes.get('/exists/:pfad{.+}', (c) => {
|
||||||
let path: string
|
let path: string
|
||||||
try {
|
try {
|
||||||
path = checkPath(decodeURIComponent(c.req.path.replace(/^\/exists\//, '')))
|
path = checkPath(c.req.param('pfad'))
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ exists: false }, 200)
|
return c.json({ exists: false }, 200)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user