feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,813 @@
|
||||
// Typed API client. All fetch calls go through here — never use fetch directly in components.
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_admin: boolean
|
||||
quota_bytes: number
|
||||
used_bytes: number
|
||||
/** Persönliche Palette – bleibt erhalten, auch wenn sie gerade nicht
|
||||
* angewendet wird (siehe use_custom_theme). */
|
||||
primary_color: string
|
||||
accent_color: string
|
||||
use_custom_theme: boolean
|
||||
/** Konto meldet sich über den Identitätsanbieter an und hat hier kein
|
||||
* Passwort – die Passwortänderung wird dann ausgeblendet. */
|
||||
is_oidc?: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: string
|
||||
/** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */
|
||||
document_date?: string
|
||||
owner_id: string | null
|
||||
group_id: string | null
|
||||
folder_id: string | null
|
||||
name: string
|
||||
mime_type: string
|
||||
size: number
|
||||
checksum: string
|
||||
has_thumbnail: boolean
|
||||
version: number
|
||||
versioning_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FolderItem {
|
||||
id: string
|
||||
/** Genau eines von beiden ist gesetzt: persönlicher Ordner oder Gruppenordner. */
|
||||
owner_id: string | null
|
||||
group_id: string | null
|
||||
parent_id: string | null
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
files: FileItem[]
|
||||
folders: FolderItem[]
|
||||
/** Etiketten je Datei-ID. Kommen mit der Liste in einer Abfrage mit – bei
|
||||
* fuenfzig Kacheln waeren fuenfzig Einzelabfragen der falsche Weg. */
|
||||
tags?: Record<string, Tag[]>
|
||||
/** Absender je Datei-ID – auf demselben Weg wie die Etiketten. */
|
||||
correspondents?: Record<string, Correspondent>
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User
|
||||
csrf_token: string
|
||||
}
|
||||
|
||||
export interface ShareLink {
|
||||
id: string
|
||||
file_id: string
|
||||
owner_id: string
|
||||
token: string
|
||||
has_password: boolean
|
||||
expires_at: string | null
|
||||
download_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateShareRequest {
|
||||
password?: string
|
||||
expires_in_hours?: number
|
||||
}
|
||||
|
||||
export interface PublicShareInfo {
|
||||
link: ShareLink
|
||||
file_name: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
version: number
|
||||
size: number
|
||||
checksum: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number
|
||||
total: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
}
|
||||
}
|
||||
|
||||
let csrfToken = ''
|
||||
|
||||
export function setCSRFToken(token: string) {
|
||||
csrfToken = token
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
isFormData = false,
|
||||
/** Abbruchsignal – die Suche verwirft damit überholte Anfragen. */
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
if (body && !isFormData) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal,
|
||||
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const json = await res.json().catch(() => ({ error: res.statusText }))
|
||||
if (!res.ok) throw new APIError(res.status, json.error ?? 'Unknown error')
|
||||
return json.data ?? json as T
|
||||
}
|
||||
|
||||
// XHR-based upload with real progress reporting.
|
||||
export function uploadWithProgress(
|
||||
file: File,
|
||||
folderId: string | undefined,
|
||||
onProgress: (p: UploadProgress) => void
|
||||
): Promise<FileItem> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
// folder_id MUST come before the file so the server can stream the file
|
||||
// straight to storage without buffering the whole body first.
|
||||
if (folderId) form.append('folder_id', folderId)
|
||||
form.append('file', file)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/v1/files')
|
||||
xhr.withCredentials = true
|
||||
if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken)
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) })
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 201) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText)
|
||||
resolve(json.data ?? json)
|
||||
} catch {
|
||||
reject(new APIError(xhr.status, 'Invalid response'))
|
||||
}
|
||||
} else {
|
||||
let msg = 'Upload failed'
|
||||
try {
|
||||
msg = JSON.parse(xhr.responseText).error ?? msg
|
||||
} catch { /* kein JSON – Standardtext behalten */ }
|
||||
// 413 kommt hier nur vom Quota-Check; der rohe Servertext sagt einem
|
||||
// Nutzer vor dem Upload-Fenster wenig.
|
||||
if (xhr.status === 413) msg = 'Storage full – your quota is not sufficient'
|
||||
reject(new APIError(xhr.status, msg))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
||||
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
|
||||
|
||||
xhr.send(form)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExternalApp {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
oidc_enabled: boolean
|
||||
cloud_name: string
|
||||
/** Leer, wenn kein Logo hinterlegt ist. Enthält einen Versionsanteil, der
|
||||
* sich bei jedem Austausch ändert – damit Browser nicht das alte zeigen. */
|
||||
logo_url?: string
|
||||
/** Tab-Symbol (64 px) aus dem hochgeladenen App-Symbol. Leer, wenn keins
|
||||
* hinterlegt ist. Ebenfalls mit Versionsanteil. */
|
||||
favicon_url?: string
|
||||
/** Dasselbe Symbol in 192 px – für den Startbildschirm von iOS. */
|
||||
apple_touch_icon_url?: string
|
||||
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
|
||||
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
|
||||
office_enabled?: boolean
|
||||
primary_color?: string
|
||||
accent_color?: string
|
||||
external_apps: ExternalApp[]
|
||||
}
|
||||
|
||||
export const publicApi = {
|
||||
config: (): Promise<PublicConfig> =>
|
||||
fetch('/api/v1/public/config').then(r => r.json()).then(j => j.data ?? j),
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<AuthResponse>('POST', '/auth/login', { username, password }),
|
||||
|
||||
logout: () => request<void>('POST', '/auth/logout'),
|
||||
|
||||
me: () => request<AuthResponse>('GET', '/auth/me'),
|
||||
|
||||
updateProfile: (displayName: string) =>
|
||||
request<User>('PATCH', '/auth/profile', { display_name: displayName }),
|
||||
|
||||
updateColors: (primaryColor: string, accentColor: string) =>
|
||||
request<User>('PATCH', '/auth/profile', {
|
||||
primary_color: primaryColor,
|
||||
accent_color: accentColor,
|
||||
}),
|
||||
|
||||
setUseCustomTheme: (enabled: boolean) =>
|
||||
request<User>('PATCH', '/auth/profile', { use_custom_theme: enabled }),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<void>('POST', '/auth/change-password', { current_password: currentPassword, new_password: newPassword }),
|
||||
}
|
||||
|
||||
// ── Internal shares (people and groups) ───────────────────────────────────────
|
||||
|
||||
export type SharePermission = 'read' | 'write'
|
||||
|
||||
export interface AclEntry {
|
||||
id: string
|
||||
subject_type: 'user' | 'group'
|
||||
subject_id: string
|
||||
subject_name: string
|
||||
permission: SharePermission
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ShareRecipient {
|
||||
id: string
|
||||
name: string
|
||||
type: 'user' | 'group'
|
||||
}
|
||||
|
||||
export interface SharedResource {
|
||||
type: 'folder' | 'file'
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
owner_name: string
|
||||
permission: SharePermission
|
||||
size: number
|
||||
mime_type: string
|
||||
shared_at: string
|
||||
/** Gesetzt, wenn der Zugriff über eine Gruppe kommt statt persönlich. */
|
||||
via_group: string
|
||||
}
|
||||
|
||||
export const aclApi = {
|
||||
recipients: () => request<ShareRecipient[]>('GET', '/share-recipients'),
|
||||
|
||||
sharedWithMe: () => request<SharedResource[]>('GET', '/shared-with-me'),
|
||||
|
||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||
|
||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
|
||||
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||
}
|
||||
|
||||
// ── App passwords (WebDAV / sync clients) ─────────────────────────────────────
|
||||
|
||||
export interface AppPassword {
|
||||
id: string
|
||||
name: string
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// The plaintext password is returned exactly once, on creation.
|
||||
export interface AppPasswordCreated extends AppPassword {
|
||||
password: string
|
||||
}
|
||||
|
||||
export const appPasswordsApi = {
|
||||
list: () => request<AppPassword[]>('GET', '/app-passwords'),
|
||||
|
||||
create: (name: string) =>
|
||||
request<AppPasswordCreated>('POST', '/app-passwords', { name }),
|
||||
|
||||
revoke: (id: string) => request<void>('DELETE', `/app-passwords/${id}`),
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Suche ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SearchHit {
|
||||
kind: 'folder' | 'file'
|
||||
id: string
|
||||
name: string
|
||||
/** Ordner, in dem der Treffer liegt – null heisst Wurzel. */
|
||||
folder_id: string | null
|
||||
folder_name: string
|
||||
mime_type?: string
|
||||
size: number
|
||||
has_thumbnail: boolean
|
||||
updated_at: string
|
||||
/** true, wenn nicht der Name passte, sondern der erkannte Text im Dokument. */
|
||||
matched_content?: boolean
|
||||
/** Fundstelle im Text; Treffer in <b>…</b>. Nur bei matched_content. */
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
export interface OcrStatus {
|
||||
enabled: boolean
|
||||
/** Fehlen alle, wenn enabled false ist. */
|
||||
pending?: number
|
||||
running?: number
|
||||
done?: number
|
||||
failed?: number
|
||||
/** Dateien mit gespeichertem Text. */
|
||||
indexed?: number
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
hits: SearchHit[]
|
||||
query: string
|
||||
/** true, wenn die Liste bei der Obergrenze abgeschnitten wurde. */
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
/** Leer heisst: die Oberflaeche leitet eine Farbe aus dem Namen ab. */
|
||||
color: string
|
||||
/** Emoji oder leer. Steht **neben** der Farbe: Die Farbe traegt die
|
||||
* Wiedererkennung im Augenwinkel, das Symbol die Bedeutung. Ab etwa fuenf
|
||||
* Etiketten reicht Farbe allein nicht mehr – dann fragt man sich, ob Blau
|
||||
* nun „Uni" oder „Versicherung" war. */
|
||||
icon: string
|
||||
/** Gesetzt, wenn das Etikett einer Gruppe gehoert – dann sehen es alle
|
||||
* Mitglieder, und es gilt nur fuer Dateien in deren Gruppenordner. */
|
||||
group_id?: string
|
||||
group_name?: string
|
||||
file_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Absender („Korrespondent") – von wem ein Dokument kommt.
|
||||
*
|
||||
* Der Unterschied zum Etikett ist die Anzahl: Etiketten sagen, *wofür* ein
|
||||
* Dokument gebraucht wird, davon trägt eine Datei beliebig viele. Der Absender
|
||||
* sagt, *von wem* es kommt – davon hat eine Datei genau einen. Deshalb ein
|
||||
* Feld und keine Mehrfachauswahl, und deshalb eine eigene Liste: Sonst stünde
|
||||
* „Stadtwerke" neben „absetzbar" in derselben Aufzählung.
|
||||
*/
|
||||
export interface Correspondent {
|
||||
id: string
|
||||
name: string
|
||||
/** Emoji oder leer – dieselbe Regel wie beim Etikett. */
|
||||
icon: string
|
||||
group_id?: string
|
||||
group_name?: string
|
||||
file_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Datei-Treffer beim Filtern nach einem Etikett. */
|
||||
export interface TaggedFile {
|
||||
id: string
|
||||
name: string
|
||||
folder_id: string | null
|
||||
folder_name: string
|
||||
mime_type: string
|
||||
size: number
|
||||
has_thumbnail: boolean
|
||||
/** Datum auf dem Beleg – nicht das des Uploads. */
|
||||
document_date?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet, dass sich an den Etiketten etwas geändert hat.
|
||||
*
|
||||
* Die Seitenleiste zeigt Zählstände, die sich ändern, sobald irgendwo
|
||||
* etikettiert wird – und sie steht neben dem Dialog, in dem das passiert. Eine
|
||||
* Zahl, die daneben stehenbleibt, während man sie gerade verändert hat, sieht
|
||||
* nach einem Fehler aus.
|
||||
*
|
||||
* Ein Ereignis statt Zustand durch drei Ebenen zu reichen: Der Dialog steckt
|
||||
* tief im Dateibrowser, die Seitenleiste hängt am Shell – ein gemeinsamer
|
||||
* Zustand nur für einen Zählstand wäre viel Verkabelung für wenig.
|
||||
*/
|
||||
export const tagsChangedEvent = 'aether:tags-changed'
|
||||
|
||||
export function notifyTagsChanged() {
|
||||
window.dispatchEvent(new Event(tagsChangedEvent))
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet einen Archiv-Download.
|
||||
*
|
||||
* Über ein abgeschicktes Formular, nicht über fetch(): Nur eine **Navigation**
|
||||
* behandelt der Browser als Download. Ein fetch() müsste die Antwort erst
|
||||
* vollständig in den Speicher holen – bei einem 5-GB-Ordner also 5 GB im
|
||||
* Arbeitsspeicher, und der Nutzer sähe bis dahin nichts.
|
||||
*
|
||||
* POST statt GET, weil eine Auswahl beliebig lang sein kann: Fünfzig
|
||||
* Kennungen sind schon knapp 1900 Zeichen, und Adressen haben eine Grenze.
|
||||
*/
|
||||
export function downloadZip(opts: { fileIds?: string[]; folderId?: string; name?: string }) {
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = '/api/v1/download/zip'
|
||||
// Das CSRF-Token als Feld – ein Formular kann keine Header setzen. Der
|
||||
// Server vergleicht es weiterhin gegen das Cookie (Double-Submit).
|
||||
const add = (name: string, value: string) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = name
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
}
|
||||
add('csrf_token', csrfToken)
|
||||
if (opts.folderId) add('folder_id', opts.folderId)
|
||||
if (opts.name) add('name', opts.name)
|
||||
for (const id of opts.fileIds ?? []) add('file_ids', id)
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
form.remove()
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => request<Tag[]>('GET', '/tags'),
|
||||
create: (name: string, color = '', groupId?: string, icon = '') =>
|
||||
request<Tag>('POST', '/tags', { name, color, icon, group_id: groupId ?? '' }),
|
||||
update: (id: string, name: string, color: string, icon = '') =>
|
||||
request<Tag>('PATCH', `/tags/${id}`, { name, color, icon }),
|
||||
remove: (id: string) => request<void>('DELETE', `/tags/${id}`),
|
||||
files: (id: string) => request<TaggedFile[]>('GET', `/tags/${id}/files`),
|
||||
/**
|
||||
* Setzt Etiketten, Belegdatum und Absender einer Datei in **einem** Vorgang.
|
||||
*
|
||||
* Alles drei steht im selben Dialog, also geht es auch zusammen zum Server –
|
||||
* sonst gäbe es einen Zustand, in dem die Etiketten sitzen und der Absender
|
||||
* nicht.
|
||||
*
|
||||
* Weglassen heisst jeweils „unveraendert", leerer String heisst „loeschen".
|
||||
* `correspondentName` legt den Absender bei Bedarf an; das ist der Normalfall
|
||||
* beim Tippen.
|
||||
*/
|
||||
setForFile: (fileId: string, tagIds: string[], documentDate?: string,
|
||||
correspondent?: { id?: string; name?: string }) =>
|
||||
request<Tag[]>('PUT', `/files/${fileId}/tags`, {
|
||||
tag_ids: tagIds,
|
||||
...(documentDate === undefined ? {} : { document_date: documentDate }),
|
||||
...(correspondent?.name ? { correspondent_name: correspondent.name }
|
||||
: correspondent?.id === undefined ? {} : { correspondent_id: correspondent.id }),
|
||||
}),
|
||||
}
|
||||
|
||||
export const correspondentsApi = {
|
||||
list: () => request<Correspondent[]>('GET', '/correspondents'),
|
||||
create: (name: string, groupId?: string, icon = '') =>
|
||||
request<Correspondent>('POST', '/correspondents', { name, icon, group_id: groupId ?? '' }),
|
||||
update: (id: string, name: string, icon = '') =>
|
||||
request<Correspondent>('PATCH', `/correspondents/${id}`, { name, icon }),
|
||||
remove: (id: string) => request<void>('DELETE', `/correspondents/${id}`),
|
||||
files: (id: string) => request<TaggedFile[]>('GET', `/correspondents/${id}/files`),
|
||||
}
|
||||
|
||||
/** Was der Server über eine Office-Sitzung mitteilt. */
|
||||
export interface OfficeSession {
|
||||
enabled: boolean
|
||||
provider?: string
|
||||
/** Adresse der Bedienoberflaeche beim Office-Server, samt WOPISrc. */
|
||||
url: string
|
||||
/** Kurzlebiges Zugriffstoken – gilt fuer **genau diese** Datei. */
|
||||
token: string
|
||||
token_ttl: number
|
||||
can_write: boolean
|
||||
file_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Endungen, die ein Office-Server bearbeiten kann.
|
||||
*
|
||||
* Dieselbe Positivliste wie im Backend (internal/office). Hier steht sie nur,
|
||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||
* entschieden wird serverseitig.
|
||||
*/
|
||||
const officeExtensions = new Set([
|
||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
||||
'ods', 'xlsx', 'xls', 'csv',
|
||||
'odp', 'pptx', 'ppt',
|
||||
'odg',
|
||||
])
|
||||
|
||||
export function isOfficeFile(name: string): boolean {
|
||||
const i = name.lastIndexOf('.')
|
||||
return i >= 0 && officeExtensions.has(name.slice(i + 1).toLowerCase())
|
||||
}
|
||||
|
||||
export const officeApi = {
|
||||
session: (fileId: string) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
||||
}
|
||||
|
||||
export const ocrApi = {
|
||||
/** Stand der Texterkennung für die **eigenen** Dateien. Braucht keine
|
||||
* Adminrechte – anders als adminApi.ocrStatus, das den ganzen Server zählt. */
|
||||
myStatus: () => request<OcrStatus>('GET', '/ocr'),
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
query: (q: string, signal?: AbortSignal) =>
|
||||
request<SearchResponse>('GET', `/search?q=${encodeURIComponent(q)}`, undefined, false, signal),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
list: (folderId?: string) => {
|
||||
const qs = folderId ? `?folder_id=${folderId}` : ''
|
||||
return request<ListResponse>('GET', `/files${qs}`)
|
||||
},
|
||||
|
||||
upload: (file: File, folderId?: string) => {
|
||||
const form = new FormData()
|
||||
// folder_id before file – see uploadWithProgress note (streaming upload).
|
||||
if (folderId) form.append('folder_id', folderId)
|
||||
form.append('file', file)
|
||||
return request<FileItem>('POST', '/files', form, true)
|
||||
},
|
||||
|
||||
download: (id: string) => `/api/v1/files/${id}/download`,
|
||||
|
||||
/** Dieselbe Datei, aber zum Anzeigen im Browser statt zum Speichern. Der
|
||||
* Server liefert das nur für eine feste Liste harmloser Typen inline aus. */
|
||||
inline: (id: string) => `/api/v1/files/${id}/download?inline=1`,
|
||||
|
||||
thumbnail: (id: string) => `/api/v1/files/${id}/thumbnail`,
|
||||
|
||||
/**
|
||||
* Der erkannte Text einer Datei, in Seiten zerlegt.
|
||||
*
|
||||
* Für die Suche im PDF-Betrachter: Bei einem Scan steht im PDF selbst kein
|
||||
* Wort, nur ein Bild – ohne diesen Weg fände die Suche im Dokument nichts.
|
||||
* `source` sagt, woher der Text stammt ('layer' aus dem PDF, 'ocr' erkannt).
|
||||
*/
|
||||
textPages: (id: string) =>
|
||||
request<{ pages: string[]; source: string }>('GET', `/files/${id}/text`),
|
||||
|
||||
delete: (id: string) => request<void>('DELETE', `/files/${id}`),
|
||||
|
||||
rename: (id: string, name: string) =>
|
||||
request<FileItem>('PATCH', `/files/${id}/rename`, { name }),
|
||||
|
||||
move: (id: string, folderId: string | null) =>
|
||||
request<FileItem>('PATCH', `/files/${id}/move`, { folder_id: folderId }),
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const foldersApi = {
|
||||
list: async (parentId?: string): Promise<FolderItem[]> => {
|
||||
const data = await filesApi.list(parentId)
|
||||
return data.folders ?? []
|
||||
},
|
||||
create: (name: string, parentId?: string) =>
|
||||
request<FolderItem>('POST', '/folders', { name, parent_id: parentId }),
|
||||
rename: (id: string, name: string) =>
|
||||
request<FolderItem>('PATCH', `/folders/${id}/rename`, { name }),
|
||||
move: (id: string, parentId: string | null) =>
|
||||
request<FolderItem>('PATCH', `/folders/${id}/move`, { parent_id: parentId }),
|
||||
delete: (id: string) =>
|
||||
request<void>('DELETE', `/folders/${id}`),
|
||||
}
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FavoriteItem {
|
||||
id: string
|
||||
folder_id: string
|
||||
folder_name: string
|
||||
folder_parent_id: string | null
|
||||
position: number
|
||||
is_expanded: boolean
|
||||
}
|
||||
|
||||
export const favoritesApi = {
|
||||
list: () => request<FavoriteItem[]>('GET', '/favorites'),
|
||||
add: (folderId: string) => request<FavoriteItem>('POST', '/favorites', { folder_id: folderId }),
|
||||
remove: (folderId: string) => request<void>('DELETE', `/favorites/${folderId}`),
|
||||
setExpanded: (folderId: string, expanded: boolean) =>
|
||||
request<void>('PATCH', `/favorites/${folderId}/expand`, { expanded }),
|
||||
reorder: (folderIds: string[]) =>
|
||||
request<void>('PATCH', '/favorites/reorder', { folder_ids: folderIds }),
|
||||
}
|
||||
|
||||
// ── Trash ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TrashItem extends FileItem {
|
||||
folder_path: string | null
|
||||
}
|
||||
|
||||
export const trashApi = {
|
||||
list: () => request<TrashItem[]>('GET', '/trash'),
|
||||
|
||||
restore: (id: string) => request<FileItem>('POST', `/trash/${id}/restore`),
|
||||
|
||||
deletePermanently: (id: string) => request<void>('DELETE', `/trash/${id}`),
|
||||
|
||||
emptyAll: () => request<void>('DELETE', '/trash'),
|
||||
}
|
||||
|
||||
// ── Shares ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const sharesApi = {
|
||||
create: (fileId: string, opts: CreateShareRequest) =>
|
||||
request<ShareLink>('POST', `/files/${fileId}/share`, opts),
|
||||
|
||||
list: () => request<ShareLink[]>('GET', '/shares'),
|
||||
|
||||
delete: (id: string) => request<void>('DELETE', `/shares/${id}`),
|
||||
|
||||
// Public endpoints (no auth) – /share/ prefix avoids chi /api/v1 middleware conflict
|
||||
getInfo: (token: string) =>
|
||||
fetch(`/share/${token}`)
|
||||
.then(r => r.json())
|
||||
.then(j => (j.data ?? j) as PublicShareInfo),
|
||||
|
||||
download: (token: string, password?: string) =>
|
||||
fetch(`/share/${token}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
}
|
||||
|
||||
// ── Versions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const versionsApi = {
|
||||
list: (fileId: string) =>
|
||||
request<FileVersion[]>('GET', `/files/${fileId}/versions`),
|
||||
|
||||
download: (fileId: string, versionId: string) =>
|
||||
`/api/v1/files/${fileId}/versions/${versionId}/download`,
|
||||
|
||||
restore: (fileId: string, versionId: string) =>
|
||||
request<FileItem>('POST', `/files/${fileId}/versions/${versionId}/restore`),
|
||||
|
||||
setEnabled: (fileId: string, enabled: boolean) =>
|
||||
request<FileItem>('PATCH', `/files/${fileId}/versioning`, { enabled }),
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_admin: boolean
|
||||
quota_bytes: number
|
||||
used_bytes: number
|
||||
groups: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminGroup {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
member_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string
|
||||
username: string
|
||||
display_name: string
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
cloud_name: string
|
||||
primary_color: string
|
||||
accent_color: string
|
||||
logo_storage_key: string
|
||||
app_icon_storage_key: string
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string
|
||||
actor_id: string | null
|
||||
actor_name: string
|
||||
action: string
|
||||
target_type: string | null
|
||||
target_id: string | null
|
||||
target_name: string | null
|
||||
ip: string | null
|
||||
detail: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GroupFolder {
|
||||
id: string
|
||||
name: string
|
||||
group_id: string
|
||||
group_name: string
|
||||
members: number
|
||||
used_bytes: number
|
||||
quota_bytes: number
|
||||
}
|
||||
|
||||
function uploadBranding(kind: 'logo' | 'icon', file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request<{ url: string }>('POST', `/admin/branding/${kind}`, form, true)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
// Branding
|
||||
uploadLogo: (file: File) => uploadBranding('logo', file),
|
||||
deleteLogo: () => request<void>('DELETE', '/admin/branding/logo'),
|
||||
uploadAppIcon: (file: File) => uploadBranding('icon', file),
|
||||
deleteAppIcon: () => request<void>('DELETE', '/admin/branding/icon'),
|
||||
|
||||
// Group folders
|
||||
listGroupFolders: () => request<GroupFolder[]>('GET', '/admin/group-folders'),
|
||||
createGroupFolder: (groupId: string, name: string) =>
|
||||
request<FolderItem>('POST', '/admin/group-folders', { group_id: groupId, name }),
|
||||
deleteGroupFolder: (id: string) => request<void>('DELETE', `/admin/group-folders/${id}`),
|
||||
|
||||
// Audit log
|
||||
listAudit: (params: { action?: string; actorId?: string; limit?: number; offset?: number } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.action) qs.set('action', params.action)
|
||||
if (params.actorId) qs.set('actor_id', params.actorId)
|
||||
if (params.limit != null) qs.set('limit', String(params.limit))
|
||||
if (params.offset != null) qs.set('offset', String(params.offset))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
|
||||
},
|
||||
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
}>('POST', '/admin/gc'),
|
||||
|
||||
/** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die
|
||||
* Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */
|
||||
ocrStatus: () => request<OcrStatus>('GET', '/admin/ocr'),
|
||||
|
||||
// Users
|
||||
listUsers: () => request<AdminUser[]>('GET', '/admin/users'),
|
||||
createUser: (data: { username: string; display_name: string; password: string; is_admin: boolean; quota_bytes: number }) =>
|
||||
request<AdminUser>('POST', '/admin/users', data),
|
||||
updateUser: (id: string, data: { display_name: string; is_admin: boolean; quota_bytes: number }) =>
|
||||
request<AdminUser>('PATCH', `/admin/users/${id}`, data),
|
||||
resetPassword: (id: string, password?: string) =>
|
||||
request<{ new_password: string }>('POST', `/admin/users/${id}/reset-password`, password ? { password } : {}),
|
||||
deleteUser: (id: string) => request<void>('DELETE', `/admin/users/${id}`),
|
||||
|
||||
// Groups
|
||||
listGroups: () => request<AdminGroup[]>('GET', '/admin/groups'),
|
||||
createGroup: (name: string, description: string) =>
|
||||
request<AdminGroup>('POST', '/admin/groups', { name, description }),
|
||||
updateGroup: (id: string, name: string, description: string) =>
|
||||
request<AdminGroup>('PATCH', `/admin/groups/${id}`, { name, description }),
|
||||
|
||||
deleteGroup: (id: string) => request<void>('DELETE', `/admin/groups/${id}`),
|
||||
listMembers: (groupId: string) => request<GroupMember[]>('GET', `/admin/groups/${groupId}/members`),
|
||||
addMember: (groupId: string, userId: string) =>
|
||||
request<void>('POST', `/admin/groups/${groupId}/members`, { user_id: userId }),
|
||||
removeMember: (groupId: string, userId: string) =>
|
||||
request<void>('DELETE', `/admin/groups/${groupId}/members/${userId}`),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request<AppSettings>('GET', '/admin/settings'),
|
||||
updateSettings: (data: Partial<AppSettings>) =>
|
||||
request<AppSettings>('PATCH', '/admin/settings', data),
|
||||
}
|
||||
|
||||
export { APIError }
|
||||
Reference in new issue
Block a user