feat: Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
UMBENENNEN gab es gar nicht -- verschieben ja, umbenennen nirgends. Jetzt fuer
Datei und Ordner. Die WARNUNG ist dabei der eigentliche Teil: beim Verschieben
wandert eine Datei, beim Umbenennen aendert sich IHRE ADRESSE, und die steht
womoeglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt
deshalb die Abrufzahl und beim Ordner die Zahl der betroffenen Dateien, BEVOR
gedrueckt wird -- eine Datei mit viertausend Abrufen umzubenennen ist etwas
anderes als eine mit null, und der Dienst ist die einzige Stelle, die den
Unterschied kennt.
Am echten Fall geprueft, dem Kollisionsfund: items/coiloverss.png (hielt die
+-Fassung) -> items/coiloverss-plus.png, alte Adresse 404, neue 200 mit 4375 B,
Vorschau mitgewandert. Ordner: probe -> beispiele, 5 Dateien, alle Pfade in
einer Transaktion umgeschrieben.
Ein Fallstrick dabei: thumbPath haengt ".webp" an. Fuer eine Datei richtig, fuer
einen ORDNER Unsinn -- der Vorschauordner heisst thumbs/vehicles und nicht
thumbs/vehicles.webp. Dafuer gibt es jetzt moveFolder.
DIE 93 KOLLISIONEN SIND ERLEDIGT. tools/kollisionen.ps1 rechnet dieselbe
Zaehmung auf der Quelle nach und zeigt, was zusammenfaellt -- es aendert nichts,
damit die Entscheidung auf Zahlen steht. Ergebnis: 91 von 93 sind dieselbe Datei
zweimal (WEAPON_SMG.png und weapon_smg.png, byteweise gleich gross), da fehlte
nichts. Echt verloren waren ZWEI, beide mit + im Namen. Beide nachgeholt.
FIVEMANAGE-SPRACHE. POST /api/image|video|audio (v1) und POST /api/v3/file (v3),
Schluessel nackt im Authorization-Kopf. Auf einem laufenden Server stecken die
Fivemanage-Aufrufe in einem Dutzend fremder Ressourcen; sie alle umzuschreiben
tut niemand, und deshalb bliebe dieser Dienst ungenutzt daneben stehen. So ist
der Umzug eine Zeile je Skript: die Adresse.
Beide Formen sind AUS ECHTEM CODE abgelesen und nicht geraten -- fivemanage/sdk
fuer v3, Awleks/Devm-Camera fuer den aelteren Weg ueber screenshot-basic.
Gemessen: v1 -> {url,id,path}, v3 -> {status:"ok",data:{id,url}}, ohne
Schluessel 401, ein Video an /api/image -> 415 mit Grund.
Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
warnt: die Token-Wache hing an use('*'), und der Einhaengepunkt ist /api -- also
galt sie auch fuer /api/dash daneben. Das Dashboard bekam 401 auf die ANMELDUNG.
Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.
FREIGABE-LINKS. /s/<schluessel> zeigt einen Ordner ohne Anmeldung. Was dabei
ausdruecklich dabeisteht, in der Karte und in der Rueckfrage vor dem
Zurueckziehen: FREIGEGEBEN WIRD DIE LISTE, NICHT DER INHALT. Die Dateien sind
ohnehin oeffentlich; ein zurueckgezogener Link macht sie nicht wieder privat, er
nimmt nur die Uebersicht weg. Ohne diesen Satz zieht jemand einen Link zurueck
und glaubt, etwas sei verschwunden.
Die Antwort ist abgemessen: Name, Groesse, Art, Adresse, Laenge. NICHT Hash,
Token, Zeitpunkte, Abrufzahlen, IDs -- nichts davon braucht, wer einen Katalog
ansieht, und jedes davon waere eine Auskunft ueber den Betrieb. Ein unbekannter
Schluessel und ein zurueckgezogener geben dieselbe Antwort.
ZAHLEN, DIE SCHON DA WAREN. Sortieren nach Abrufen und nach "zuletzt geholt";
die Kachel zeigt dann auch diese Zahl statt Groesse und Datum, denn nach etwas
zu ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begruendung. Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war NIRGENDS zu sehen. Gemessen: d4rk_photostudio haelt 810
Dateien / 99,5 MB, 3640 liegen ohne Token da.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
pruneEvents,
|
||||
pruneSessions,
|
||||
type Media,
|
||||
type Freigabe,
|
||||
type MediaEvent,
|
||||
type PapierkorbEintrag,
|
||||
type Token,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
} from '../settings.js'
|
||||
import { discordRoutes, redirectUri } from './discord.js'
|
||||
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
||||
import { neuerSchluessel } from './freigabe.js'
|
||||
import {
|
||||
absolutePath,
|
||||
checkPath,
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
lohntWebp,
|
||||
mimeFor,
|
||||
moveFile,
|
||||
moveFolder,
|
||||
publicUrlFor,
|
||||
safeFilename,
|
||||
sha256,
|
||||
@@ -362,6 +365,11 @@ dashRoutes.get('/media', (c) => {
|
||||
angelegt: 'created_at',
|
||||
pfad: 'path',
|
||||
groesse: 'size',
|
||||
// Gezaehlt wurde laengst, sortieren konnte man danach nicht -- eine
|
||||
// Zahl, die man nicht ordnen kann, beantwortet die Frage "was wird
|
||||
// eigentlich gebraucht" nur fuer die zehn im Speicherbericht.
|
||||
abrufe: 'abrufe',
|
||||
geholt: 'zuletzt_abgerufen',
|
||||
}
|
||||
const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at'
|
||||
const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC'
|
||||
@@ -1413,6 +1421,138 @@ dashRoutes.post('/media/move', async (c) => {
|
||||
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
||||
})
|
||||
|
||||
/** Eine Datei umbenennen.
|
||||
*
|
||||
* Verschieben gab es, Umbenennen nicht -- und das ist genau derselbe Vorgang
|
||||
* mit einem anderen letzten Stueck. Getrennt gehalten wird es trotzdem, weil
|
||||
* die WARNUNG eine andere ist: beim Verschieben wandert eine Datei, beim
|
||||
* Umbenennen aendert sich IHRE ADRESSE, und die steht womoeglich in einem
|
||||
* Skript, das wir nicht sehen.
|
||||
*
|
||||
* DESHALB SAGT DIE ANTWORT, WAS DIE ALTE ADRESSE WAR und wie oft sie geholt
|
||||
* wurde. Eine Datei mit 4000 Abrufen umzubenennen ist etwas anderes als eine
|
||||
* mit null -- und der Dienst ist die einzige Stelle, die das weiss.
|
||||
*
|
||||
* Nur der NAME wird geaendert, nicht der Ordner. Wer beides will, benennt um
|
||||
* und verschiebt; zwei sichtbare Schritte sind hier besser als einer, der
|
||||
* beides kann und dabei unklar wird. */
|
||||
dashRoutes.post('/media/umbenennen', async (c) => {
|
||||
const body = await jsonBody<{ id?: number; name?: string }>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
if (!body.id || !body.name?.trim()) return c.json({ error: 'id und name noetig' }, 400)
|
||||
|
||||
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(body.id) as Media | undefined
|
||||
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
||||
|
||||
// Der Name kommt aus einem Formular und wird NICHT gezaehmt, sondern
|
||||
// geprueft: wer hier umbenennt, weiss, welche Adresse er will. Ein
|
||||
// stillschweigend zurechtgebogener Name waere schlimmer als eine
|
||||
// Fehlermeldung -- man merkte es erst, wenn das Bild fehlt.
|
||||
const name = body.name.trim()
|
||||
if (name.includes('/')) {
|
||||
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten — zum Verschieben gibt es "Verschieben".' }, 400)
|
||||
}
|
||||
|
||||
const ordner = row.path.split('/').slice(0, -1).join('/')
|
||||
let neu: string
|
||||
try {
|
||||
neu = checkPath(ordner ? `${ordner}/${name}` : name)
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (neu === row.path) return c.json({ error: 'heisst schon so' }, 400)
|
||||
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)) {
|
||||
return c.json({ error: `${neu} ist belegt` }, 409)
|
||||
}
|
||||
|
||||
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', c.get('user').username, row.path)
|
||||
|
||||
return c.json({ alt: row.path, neu, url: publicUrlFor(neu), abrufe: row.abrufe })
|
||||
})
|
||||
|
||||
/** Einen Ordner umbenennen -- mit allem, was darin liegt.
|
||||
*
|
||||
* DAS IST DER GROSSE HEBEL und deshalb der vorsichtigere Weg: hier aendern
|
||||
* sich auf einen Schlag hunderte Adressen. "items" nach "gegenstaende" zu
|
||||
* benennen macht 3576 URLs ungueltig, und keine davon meldet sich.
|
||||
*
|
||||
* Die Datenbank wird in EINER Transaktion umgeschrieben. Nicht aus Eile: wenn
|
||||
* auf halbem Weg etwas schiefgeht, zeigt sonst die Haelfte der Eintraege auf
|
||||
* einen Ordner, den es nicht mehr gibt. Das Verschieben auf der Platte ist ein
|
||||
* einziges `rename` und damit ohnehin unteilbar. */
|
||||
dashRoutes.post('/folders/umbenennen', async (c) => {
|
||||
const body = await jsonBody<{ pfad?: string; name?: string }>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
if (!body.pfad?.trim() || !body.name?.trim()) {
|
||||
return c.json({ error: 'pfad und name noetig' }, 400)
|
||||
}
|
||||
|
||||
const name = body.name.trim()
|
||||
if (name.includes('/')) {
|
||||
return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten' }, 400)
|
||||
}
|
||||
|
||||
let alt: string
|
||||
let neu: string
|
||||
try {
|
||||
alt = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
|
||||
const oben = alt.split('/').slice(0, -1).join('/')
|
||||
neu = checkPath(oben ? `${oben}/${name}` : name)
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (neu === alt) return c.json({ error: 'heisst schon so' }, 400)
|
||||
|
||||
// Der Zielordner darf nicht schon belegt sein -- weder in der Datenbank
|
||||
// noch auf der Platte. Sonst schoebe `rename` den einen IN den anderen
|
||||
// hinein, was etwas voellig anderes ist als umbenennen.
|
||||
const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||
const drin = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||
.get(`${maskiert(neu)}/%`) as { n: number }
|
||||
if (drin.n > 0) return c.json({ error: `"${neu}" gibt es schon (${drin.n} Dateien)` }, 409)
|
||||
try {
|
||||
await stat(join(config.filesDir, neu))
|
||||
return c.json({ error: `"${neu}" gibt es schon` }, 409)
|
||||
} catch {
|
||||
/* frei — weiter */
|
||||
}
|
||||
|
||||
const betroffen = db
|
||||
.prepare(`SELECT id, path, size FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||
.all(`${maskiert(alt)}/%`) as { id: number; path: string; size: number }[]
|
||||
|
||||
try {
|
||||
await moveFolder(alt, neu)
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{ error: `Verschieben fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}` },
|
||||
500,
|
||||
)
|
||||
}
|
||||
|
||||
const wer = c.get('user').username
|
||||
const zeit = now()
|
||||
const schreiben = db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?')
|
||||
db.transaction(() => {
|
||||
for (const r of betroffen) {
|
||||
schreiben.run(neu + r.path.slice(alt.length), zeit, r.id)
|
||||
}
|
||||
})()
|
||||
|
||||
// EIN Eintrag im Verlauf und nicht 3576. Wer nachliest, was passiert ist,
|
||||
// will "items -> gegenstaende" lesen und nicht dreitausend Zeilen davon.
|
||||
logEvent('move', neu, 0, 'user', wer, alt)
|
||||
|
||||
return c.json({ alt, neu, dateien: betroffen.length })
|
||||
})
|
||||
|
||||
/** Einen leeren Ordner anlegen.
|
||||
*
|
||||
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
|
||||
@@ -1701,6 +1841,64 @@ dashRoutes.post('/papierkorb/leeren', async (c) => {
|
||||
return c.json({ geleert: weg })
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------- Freigaben
|
||||
//
|
||||
// Was hier verwaltet wird, ist die LISTE eines Ordners -- die Dateien selbst
|
||||
// sind ohnehin oeffentlich. Das steht auch in der Oberflaeche, damit niemand
|
||||
// glaubt, eine Freigabe zu widerrufen mache seine Bilder wieder privat.
|
||||
|
||||
dashRoutes.get('/freigaben', (c) => {
|
||||
const items = (
|
||||
db.prepare('SELECT * FROM freigaben ORDER BY created_at DESC').all() as Freigabe[]
|
||||
).map((f) => ({ ...f, url: `${config.publicUrl}/s/${f.schluessel}` }))
|
||||
return c.json({ items })
|
||||
})
|
||||
|
||||
dashRoutes.post('/freigaben', async (c) => {
|
||||
const body = await jsonBody<{ ordner?: string; titel?: string }>(c)
|
||||
if (!body?.ordner?.trim()) return c.json({ error: 'kein Ordner' }, 400)
|
||||
|
||||
let ordner: string
|
||||
try {
|
||||
ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, ''))
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
}
|
||||
|
||||
// Einen Link auf einen Ordner anzulegen, den es nicht gibt, ergibt eine
|
||||
// leere Seite und die Frage, woran es liegt. Lieber hier ablehnen.
|
||||
const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||
const drin = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
|
||||
.get(`${maskiert}/%`) as { n: number }
|
||||
if (drin.n === 0) return c.json({ error: `In "${ordner}" liegt nichts.` }, 400)
|
||||
|
||||
const schluessel = neuerSchluessel()
|
||||
const eintrag = db
|
||||
.prepare(
|
||||
`INSERT INTO freigaben (schluessel, ordner, titel, created_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(schluessel, ordner, (body.titel ?? '').trim(), now())
|
||||
|
||||
logEvent('upload', ordner, 0, 'user', c.get('user').username)
|
||||
|
||||
return c.json({
|
||||
id: Number(eintrag.lastInsertRowid),
|
||||
schluessel,
|
||||
ordner,
|
||||
dateien: drin.n,
|
||||
url: `${config.publicUrl}/s/${schluessel}`,
|
||||
})
|
||||
})
|
||||
|
||||
dashRoutes.delete('/freigaben/:id', (c) => {
|
||||
const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id')))
|
||||
if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
dashRoutes.get('/speicher', (c) => {
|
||||
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
||||
const z = db
|
||||
@@ -1783,6 +1981,26 @@ dashRoutes.get('/stats', (c) => {
|
||||
|
||||
// ------------------------------------------------------------------- Tokens
|
||||
|
||||
/** Was mit einem Token abgelegt wurde.
|
||||
*
|
||||
* `media.token_id` wird seit dem ersten Tag geschrieben und war bis hierhin
|
||||
* NIRGENDS zu sehen. Dabei ist es die Auskunft, die vor dem Loeschen eines
|
||||
* Tokens zaehlt: "dieser Token haelt 3576 Dateien" ist etwas anderes als
|
||||
* "dieser Token ist ungenutzt".
|
||||
*
|
||||
* Der Dashboard-Upload traegt bewusst KEINEN Token (dort gibt es eine
|
||||
* Sitzung), diese Dateien stehen deshalb unter `ohneToken`. */
|
||||
function tokenNutzung(): Map<number, { dateien: number; bytes: number; letzte: number | null }> {
|
||||
const zeilen = db
|
||||
.prepare(
|
||||
`SELECT token_id, COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes,
|
||||
MAX(created_at) AS letzte
|
||||
FROM media WHERE token_id IS NOT NULL GROUP BY token_id`,
|
||||
)
|
||||
.all() as { token_id: number; dateien: number; bytes: number; letzte: number }[]
|
||||
return new Map(zeilen.map((z) => [z.token_id, z]))
|
||||
}
|
||||
|
||||
dashRoutes.get('/tokens', (c) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
@@ -1791,6 +2009,14 @@ dashRoutes.get('/tokens', (c) => {
|
||||
FROM tokens ORDER BY created_at DESC`,
|
||||
)
|
||||
.all() as Omit<Token, 'hash'>[]
|
||||
|
||||
const nutzung = tokenNutzung()
|
||||
const ohneToken = db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes FROM media WHERE token_id IS NULL',
|
||||
)
|
||||
.get() as { dateien: number; bytes: number }
|
||||
|
||||
return c.json({
|
||||
items: rows.map((r) => ({
|
||||
...r,
|
||||
@@ -1798,7 +2024,14 @@ dashRoutes.get('/tokens', (c) => {
|
||||
// Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe
|
||||
// Bedingung, und eine davon ist irgendwann falsch.
|
||||
abgelaufen: Boolean(r.expires_at && r.expires_at < now()),
|
||||
dateien: nutzung.get(r.id)?.dateien ?? 0,
|
||||
bytes: nutzung.get(r.id)?.bytes ?? 0,
|
||||
// `last_used_at` sagt, wann zuletzt jemand mit diesem Token
|
||||
// ANGEKLOPFT hat -- auch bei einem Loeschversuch oder einem
|
||||
// Fehlschlag. Das hier sagt, wann zuletzt etwas dabei HERAUSKAM.
|
||||
zuletztAbgelegt: nutzung.get(r.id)?.letzte ?? null,
|
||||
})),
|
||||
ohneToken,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user