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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
import { useState, useEffect } from 'react'
import {
aclApi,
type AclEntry, type ShareRecipient, type SharePermission,
} from '../api/client'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
resource: { type: 'folder' | 'file'; id: string; name: string }
onClose: () => void
}
/** Freigabe an Personen und Gruppen innerhalb der Cloud.
*
* Bewusst getrennt vom öffentlichen Freigabelink: Der eine gibt einen
* anonymen Zugang ins Internet, der andere benennt Personen, die sich
* ohnehin anmelden. Ein Dialog für beides würde die Frage „wer kann das
* sehen?" verwischen – und genau die muss beim Teilen eindeutig sein. */
export default function ShareWithDialog({ resource, onClose }: Props) {
const overlay = useOverlayDismiss(onClose)
const [entries, setEntries] = useState<AclEntry[]>([])
const [recipients, setRecipients] = useState<ShareRecipient[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subject, setSubject] = useState('')
const [permission, setPermission] = useState<SharePermission>('read')
const [saving, setSaving] = useState(false)
useEffect(() => { load() }, [resource.id])
async function load() {
setLoading(true)
try {
const [list, people] = await Promise.all([
resource.type === 'folder'
? aclApi.listForFolder(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
setEntries(list ?? [])
setRecipients(people ?? [])
} catch {
setError('Could not load sharing settings')
} finally {
setLoading(false)
}
}
async function grant() {
const chosen = recipients.find(r => r.id === subject)
if (!chosen) return
setSaving(true)
setError('')
try {
if (resource.type === 'folder') {
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
} else {
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
}
setSubject('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not share')
} finally {
setSaving(false)
}
}
async function revoke(entry: AclEntry) {
try {
await aclApi.revoke(entry.id)
setEntries(list => list.filter(e => e.id !== entry.id))
} catch {
setError('Could not remove access')
}
}
// Wer schon Zugriff hat, gehört nicht noch einmal in die Auswahlliste.
const alreadyShared = new Set(entries.map(e => e.subject_id))
const available = recipients.filter(r => !alreadyShared.has(r.id))
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 300,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16, width: 480, maxWidth: '100%', maxHeight: '85vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}>
{/* Kopf */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
Share with people
</h3>
<p style={{
margin: '3px 0 0', fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{resource.name}
</p>
</div>
{/* Hinzufügen */}
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={subject}
onChange={e => setSubject(e.target.value)}
style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
>
<option value="">Choose a person or group…</option>
{available.filter(r => r.type === 'user').length > 0 && (
<optgroup label="People">
{available.filter(r => r.type === 'user').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
{available.filter(r => r.type === 'group').length > 0 && (
<optgroup label="Groups">
{available.filter(r => r.type === 'group').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
</select>
<select
value={permission}
onChange={e => setPermission(e.target.value as SharePermission)}
style={{
padding: '8px 10px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
>
<option value="read">Can view</option>
<option value="write">Can edit</option>
</select>
<button
onClick={grant}
disabled={!subject || saving}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600,
cursor: !subject || saving ? 'default' : 'pointer',
opacity: !subject || saving ? 0.5 : 1,
}}
>
{saving ? '…' : 'Share'}
</button>
</div>
{resource.type === 'folder' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Everything inside this folder is shared too, including anything added later.
Files stay in your storage and count towards your quota.
</p>
)}
{error && (
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
{/* Wer hat Zugriff */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
<div style={{
fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 10,
}}>
Who has access
</div>
{loading ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
Only you. Nobody else can see this.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.map(e => (
<div key={e.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 11px', borderRadius: 9,
border: '1px solid var(--color-border)',
}}>
<span style={{ color: 'var(--color-text-muted)', display: 'flex', flexShrink: 0 }}>
{e.subject_type === 'group' ? <GroupIcon /> : <PersonIcon />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{e.subject_name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{e.subject_type === 'group' ? 'Group' : 'Person'} ·{' '}
{e.permission === 'write' ? 'Can edit' : 'Can view'}
</div>
</div>
<button
onClick={() => revoke(e)}
style={{
padding: '4px 11px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
}}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
<div style={{
padding: '12px 22px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end',
}}>
<button
onClick={onClose}
style={{
padding: '8px 18px', borderRadius: 9,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', fontSize: 13, cursor: 'pointer',
}}
>
Done
</button>
</div>
</div>
</div>
)
}
function PersonIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a7 7 0 0114 0v1" />
</svg>
)
}
function GroupIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)
}