feat: verschieben, Ordner, Adressvorlagen, API-Seite mit Export
VERSCHIEBEN. Dateien in einen anderen Ordner legen -- mit einer Warnung, die
OBEN steht und nicht im Kleingedruckten: der Pfad IST die oeffentliche Adresse,
nach dem Verschieben fuehrt die alte URL ins Leere. Das ist kein Fehler,
sondern der Preis vorhersagbarer Adressen; man muss es nur vorher wissen.
Der Umzug ist ein `rename` und kein Kopieren-dann-Loeschen: auf derselben
Platte ist das unteilbar. Die Vorschau zieht mit, sonst zeigte die Galerie am
neuen Ort nichts und am alten das Bild einer Datei, die dort nicht mehr liegt.
Eine belegte Zieladresse wird NICHT still ueberschrieben -- da verschwaende
sonst eine fremde Datei, und der Verlauf zeigte nur einen Umzug.
Der Verlauf kennt dafuer eine neue Spalte `von` und eine neue Art `move`; ohne
sie stuende dort nur das Ziel und die Frage "wo war die vorher" waere nicht
mehr zu beantworten.
ORDNER anlegen und entfernen. Leere Ordner gibt es sonst nicht -- sie sind der
Teil eines Pfades vor dem letzten Schraegstrich, mehr nicht. Jetzt sind sie ein
echtes Verzeichnis, und /folders liest Tabelle und Platte zusammen. Ein Ordner
MIT Inhalt wird nicht mitgeloescht: das waere ein Knopf, der hunderte Dateien
wegraeumt, ohne sie zu zeigen.
ADRESSVORLAGEN. Der Bauplan einer oeffentlichen Adresse, benannt und
gespeichert (vehicles/{model}.webp). Sie werden auf der Startseite und der
API-Seite mit ausgefuellter Adresse gezeigt. Ohne Platzhalter oder mit ".." im
Muster wird abgelehnt -- beides nachgemessen.
API-SEITE. Anleitung fuer die eigenen Skripte, und der Punkt daran: die
Beispiele stehen MIT DER ECHTEN ADRESSE drin statt mit example.com. Wer
abschreibt, schreibt keinen Tippfehler ab. Dazu Export in drei Formen -- JSON,
CSV (Semikolon und CRLF, sonst steht in Excel alles in einer Spalte) und eine
fertige Lua-Tabelle Name -> Adresse, die man in eine Resource legt.
DREI FEHLER BEIM MESSEN GEFUNDEN:
- rm mit recursive:false wirft bei einem Verzeichnis EISDIR und sagt damit
etwas ganz anderes, als der Fall ist. rmdir scheitert von selbst, wenn der
Ordner nicht leer ist -- genau das gewuenschte Verhalten.
- In der Lua-Ausgabe war "\'" gemeint und "\'" geschrieben: in JavaScript ist
das schlicht ein Apostroph, der Backslash faellt weg. Ein Modellname mit
Apostroph haette die erzeugte Datei zerlegt.
- Der Export mit Ordnerfilter antwortete mit einem NACKTEN 500 (kaputtes
ESCAPE in der SQL-Zeile). Ausgerechnet das.
Deshalb hat der Dienst jetzt eine AUFFANGLINIE: app.onError gibt einen Text
statt "Internal Server Error". Wir haben das bisher an den bekannten Stellen
einzeln abgefangen -- und prompt hat ein Tippfehler die Luecke gefunden. Eine
Stelle schliesst sie fuer alles, woran heute niemand denkt.
Dazu: die Verlaufstabelle hat feste Spaltenbreiten (colgroup) und ueberall
dieselben Abstaende -- vorher hatten Kopf und Koerper verschiedene, und der
Pfad wurde abgeschnitten, obwohl daneben Platz frei war. Und die Anmeldeseite
hat zwei weiche Lichter statt einer leeren Flaeche.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+283
-2
@@ -31,7 +31,7 @@ import {
|
||||
userForSession,
|
||||
verifyPassword,
|
||||
} from '../auth.js'
|
||||
import { readdir, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DISCORD_KEYS,
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
discordConfig,
|
||||
getSetting,
|
||||
setSetting,
|
||||
setzeVorlagen,
|
||||
sicherungConfig,
|
||||
vorlagen,
|
||||
type Vorlage,
|
||||
} from '../settings.js'
|
||||
import { discordRoutes, redirectUri } from './discord.js'
|
||||
import {
|
||||
@@ -49,6 +52,7 @@ import {
|
||||
deleteFile,
|
||||
kannVorschau,
|
||||
mimeFor,
|
||||
moveFile,
|
||||
publicUrlFor,
|
||||
safeFilename,
|
||||
sha256,
|
||||
@@ -414,7 +418,7 @@ dashRoutes.get('/arten', (c) => {
|
||||
return c.json(out)
|
||||
})
|
||||
|
||||
dashRoutes.get('/folders', (c) => {
|
||||
dashRoutes.get('/folders', async (c) => {
|
||||
const rows = db.prepare('SELECT path, size FROM media').all() as {
|
||||
path: string
|
||||
size: number
|
||||
@@ -434,6 +438,25 @@ dashRoutes.get('/folders', (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Leere Ordner gibt es nur auf der Platte -- sie stehen in keiner Tabelle,
|
||||
// weil dort nur Dateien stehen. Wer einen anlegt, um etwas hineinzulegen,
|
||||
// soll ihn trotzdem sehen.
|
||||
async function verzeichnisse(rel: string, tiefe: number) {
|
||||
let eintraege
|
||||
try {
|
||||
eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const e of eintraege) {
|
||||
if (!e.isDirectory()) continue
|
||||
const p = rel ? `${rel}/${e.name}` : e.name
|
||||
if (!ordner.has(p)) ordner.set(p, { files: 0, bytes: 0, tiefe })
|
||||
await verzeichnisse(p, tiefe + 1)
|
||||
}
|
||||
}
|
||||
await verzeichnisse('', 0)
|
||||
|
||||
const items = [...ordner.entries()]
|
||||
.map(([folder, wert]) => ({ folder, ...wert }))
|
||||
// Alphabetisch und nicht nach Groesse: eine Baumansicht, deren
|
||||
@@ -781,6 +804,7 @@ dashRoutes.get('/settings', (c) => {
|
||||
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
|
||||
},
|
||||
},
|
||||
vorlagen: vorlagen(),
|
||||
sicherung: (() => {
|
||||
const sc = sicherungConfig()
|
||||
let letzte: unknown = null
|
||||
@@ -805,6 +829,7 @@ dashRoutes.post('/settings', async (c) => {
|
||||
guildId?: string
|
||||
roleIds?: string | string[]
|
||||
}
|
||||
vorlagen?: { name?: string; muster?: string }[]
|
||||
sicherung?: {
|
||||
basis?: string
|
||||
benutzer?: string
|
||||
@@ -815,6 +840,50 @@ dashRoutes.post('/settings', async (c) => {
|
||||
}>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
|
||||
// -- Adressvorlagen -----------------------------------------------------
|
||||
if (body.vorlagen) {
|
||||
if (!Array.isArray(body.vorlagen)) {
|
||||
return c.json({ error: 'Vorlagen muessen eine Liste sein' }, 400)
|
||||
}
|
||||
if (body.vorlagen.length > 20) {
|
||||
return c.json({ error: 'Hoechstens 20 Vorlagen' }, 400)
|
||||
}
|
||||
|
||||
const sauber: Vorlage[] = []
|
||||
for (const v of body.vorlagen) {
|
||||
const name = String(v?.name ?? '').trim().slice(0, 40)
|
||||
const muster = String(v?.muster ?? '').trim().replace(/^\/+/, '')
|
||||
if (!name || !muster) continue
|
||||
|
||||
// Das Muster muss einen Platzhalter haben -- sonst ist es keine
|
||||
// Vorlage, sondern eine einzelne Adresse.
|
||||
if (!/\{[a-z]+\}/.test(muster)) {
|
||||
return c.json(
|
||||
{ error: `"${name}": im Muster fehlt ein Platzhalter, z. B. {model}` },
|
||||
400,
|
||||
)
|
||||
}
|
||||
// Und es muss ein gueltiger Pfad WERDEN koennen. Geprueft wird mit
|
||||
// eingesetztem Platzhalter, sonst scheitert checkPath an der
|
||||
// geschweiften Klammer.
|
||||
try {
|
||||
checkPath(muster.replace(/\{[a-z]+\}/g, 'x'))
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{
|
||||
error: `"${name}": ${err instanceof PathError ? err.message : 'ungueltiges Muster'}`,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
sauber.push({ name, muster })
|
||||
}
|
||||
setzeVorlagen(sauber)
|
||||
if (!body.discord && !body.sicherung) {
|
||||
return c.json({ ok: true, vorlagen: sauber })
|
||||
}
|
||||
}
|
||||
|
||||
// -- Sicherung ----------------------------------------------------------
|
||||
if (body.sicherung) {
|
||||
const sic = body.sicherung
|
||||
@@ -1009,6 +1078,218 @@ dashRoutes.get('/uebersicht', (c) => {
|
||||
ordner,
|
||||
tokens,
|
||||
discord: discordConfig().bereit,
|
||||
vorlagen: vorlagen(),
|
||||
})
|
||||
})
|
||||
|
||||
/** Dateien in einen anderen Ordner legen.
|
||||
*
|
||||
* ACHTUNG, UND DIE OBERFLAECHE SAGT ES AUCH: der Pfad IST die oeffentliche
|
||||
* Adresse. Wer ein Bild verschiebt, macht jede Stelle ungueltig, die die alte
|
||||
* URL benutzt -- im Handy, in einer Vorlage, in einem Zwischenspeicher. Das
|
||||
* ist kein Fehler dieser Funktion, sondern der Preis vorhersagbarer Adressen.
|
||||
*
|
||||
* Jede Datei bekommt ihr eigenes Ergebnis, und eine Kollision bricht die
|
||||
* anderen nicht ab. */
|
||||
dashRoutes.post('/media/move', async (c) => {
|
||||
const body = await jsonBody<{ ids?: number[]; ziel?: string }>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0) {
|
||||
return c.json({ error: 'keine Auswahl' }, 400)
|
||||
}
|
||||
|
||||
let ziel = ''
|
||||
if (body.ziel && body.ziel.trim()) {
|
||||
try {
|
||||
ziel = checkPath(body.ziel.trim().replace(/^\/+|\/+$/g, ''))
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: `Ziel: ${err.message}` }, 400)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const wer = c.get('user').username
|
||||
const ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] = []
|
||||
|
||||
for (const id of body.ids) {
|
||||
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
|
||||
if (!row) {
|
||||
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
|
||||
continue
|
||||
}
|
||||
|
||||
const name = row.path.split('/').pop() as string
|
||||
const neu = ziel ? `${ziel}/${name}` : name
|
||||
|
||||
if (neu === row.path) {
|
||||
ergebnisse.push({ path: row.path, ok: false, error: 'liegt schon dort' })
|
||||
continue
|
||||
}
|
||||
|
||||
const belegt = db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)
|
||||
if (belegt) {
|
||||
// Bewusst KEIN stilles Ueberschreiben: hier wuerde eine fremde
|
||||
// Datei verschwinden, und der Verlauf zeigte nur einen Umzug.
|
||||
ergebnisse.push({ path: row.path, ok: false, error: `${neu} ist belegt` })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFile(row.path, neu)
|
||||
db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(
|
||||
neu,
|
||||
now(),
|
||||
row.id,
|
||||
)
|
||||
logEvent('move', neu, row.size, 'user', wer, row.path)
|
||||
ergebnisse.push({ path: row.path, ok: true, neu })
|
||||
} catch (err) {
|
||||
ergebnisse.push({
|
||||
path: row.path,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'unbekannt',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const gut = ergebnisse.filter((e) => e.ok).length
|
||||
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
||||
})
|
||||
|
||||
/** Einen leeren Ordner anlegen.
|
||||
*
|
||||
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
|
||||
* dem letzten Schraegstrich, mehr nicht. Ein leerer Ordner ist deshalb ein
|
||||
* echtes Verzeichnis auf der Platte und in keiner Tabelle -- /folders liest
|
||||
* beides und fuehrt es zusammen. */
|
||||
dashRoutes.post('/folders', async (c) => {
|
||||
const body = await jsonBody<{ pfad?: string }>(c)
|
||||
if (!body?.pfad?.trim()) return c.json({ error: 'kein Pfad' }, 400)
|
||||
|
||||
let pfad: string
|
||||
try {
|
||||
pfad = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
}
|
||||
|
||||
await mkdir(join(config.filesDir, pfad), { recursive: true })
|
||||
return c.json({ ok: true, pfad })
|
||||
})
|
||||
|
||||
/** Einen Ordner entfernen -- nur wenn er leer ist.
|
||||
*
|
||||
* Ein Ordner mit Inhalt wird NICHT mitgeloescht: das waere ein Knopf, der
|
||||
* hunderte Dateien wegraeumt, ohne sie zu zeigen. Wer sie loeschen will,
|
||||
* waehlt sie in der Galerie aus und sieht dabei, was verschwindet. */
|
||||
dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
|
||||
let pfad: string
|
||||
try {
|
||||
pfad = checkPath(c.req.param('pfad'))
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
}
|
||||
|
||||
const drin = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||
.get(`${pfad.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`) as { n: number }
|
||||
if (drin.n > 0) {
|
||||
return c.json({ error: `Der Ordner enthaelt noch ${drin.n} Dateien.` }, 409)
|
||||
}
|
||||
|
||||
try {
|
||||
// rmdir und nicht rm: es scheitert VON SELBST, wenn der Ordner nicht
|
||||
// leer ist -- und genau das ist hier erwuenscht. `rm` ohne recursive
|
||||
// wirft dagegen EISDIR und sagt damit etwas ganz anderes, als der
|
||||
// Fall ist (einmal darauf hereingefallen).
|
||||
await rmdir(join(config.filesDir, pfad))
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : 'liess sich nicht entfernen' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
// Die Vorschauen desselben Ordners hinterher, falls einer da ist.
|
||||
await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch(
|
||||
() => {},
|
||||
)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
/** Die Medienliste zum Mitnehmen.
|
||||
*
|
||||
* Wofuer: ein Skript, das die Adressen kennen muss, ohne die API zu fragen --
|
||||
* eine Vorlage im Handy, eine Liste zum Abgleichen, ein Datensatz fuer eine
|
||||
* andere Resource. Drei Formen, weil drei verschiedene Werkzeuge sie lesen.
|
||||
*
|
||||
* Die Lua-Form ist KEINE Spielerei: eine Datei, die man in eine Resource legt
|
||||
* und mit `local urls = ...` einliest, spart genau den Zwischenschritt, an dem
|
||||
* sonst jemand von Hand abtippt.
|
||||
*
|
||||
* Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen
|
||||
* sind das 40 KB. */
|
||||
dashRoutes.get('/export', (c) => {
|
||||
const form = c.req.query('format') ?? 'json'
|
||||
const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
|
||||
|
||||
const where = ordner ? `WHERE path LIKE ? ESCAPE '\\'` : ''
|
||||
const params = ordner ? [`${ordner.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`] : []
|
||||
|
||||
const rows = (
|
||||
db.prepare(`SELECT * FROM media ${where} ORDER BY path COLLATE NOCASE`).all(...params) as Media[]
|
||||
).map((row) => ({ ...row, url: publicUrlFor(row.path) }))
|
||||
|
||||
const stempel = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (form === 'csv') {
|
||||
// Semikolon: Excel in deutscher Einstellung nimmt sonst alles in eine
|
||||
// Spalte, und dann ist die Datei fuer den haeufigsten Leser wertlos.
|
||||
const kopf = 'pfad;url;groesse;typ;sha256;geaendert'
|
||||
const zeilen = rows.map((r) =>
|
||||
[r.path, r.url, r.size, r.mime, r.sha256, new Date(r.updated_at).toISOString()]
|
||||
.map((f) => `"${String(f).replace(/"/g, '""')}"`)
|
||||
.join(';'),
|
||||
)
|
||||
// CRLF, weil Excel es so erwartet — mit blossem \n steht die halbe
|
||||
// Datei in einer Zelle.
|
||||
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.csv"`,
|
||||
})
|
||||
}
|
||||
|
||||
if (form === 'lua') {
|
||||
// Der Dateiname ohne Endung als Schluessel: genau danach sucht ein
|
||||
// Skript ("adder"), nicht nach "vehicles/adder.webp".
|
||||
const eintraege = rows.map((r) => {
|
||||
const name = (r.path.split('/').pop() ?? r.path).replace(/\.[^.]+$/, '')
|
||||
// "\\'" und nicht "\'": in JavaScript ist Letzteres schlicht ein
|
||||
// Apostroph, der Backslash faellt weg — und ein Modellname mit
|
||||
// Apostroph zerlegte damit die erzeugte Lua-Datei.
|
||||
return ` ['${name.replace(/'/g, "\\'")}'] = '${r.url}',`
|
||||
})
|
||||
const text = [
|
||||
'-- Erzeugt von d4rk_media am ' + new Date().toISOString(),
|
||||
'-- ' + rows.length + ' Eintraege' + (ordner ? ` aus ${ordner}/` : ''),
|
||||
'--',
|
||||
'-- Verwendung: local urls = require(...) bzw. in einer Resource',
|
||||
'-- als shared_script einbinden und Urls.<name> lesen.',
|
||||
'Urls = {',
|
||||
...eintraege,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
return c.body(text, 200, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.lua"`,
|
||||
})
|
||||
}
|
||||
|
||||
return c.body(JSON.stringify({ erzeugt: now(), anzahl: rows.length, items: rows }, null, 2), 200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.json"`,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user