feat(nextcloud): per-user Nextcloud library integration

Lets each mule-image user (matched via OIDC preferred_username,
overridable in Settings) browse their Nextcloud files/ tree from the
mule-image UI and register subfolders as per-user SourceRoots. Reads
stay direct on the bind-mounted /nextcloud-users path; mutations
(upload, delete, rename, move within NC) dispatch through Nextcloud
WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients
stay coherent.

Backend:
- users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest,
  key derived from SECRET_KEY) — alembic 0016
- services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE,
  MOVE) with HTTP Basic auth via the per-user app password
- routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE
  /source-roots (path-scoped to current_user.nextcloud_username with
  realpath traversal guard)
- PATCH /api/v1/auth/me to update nextcloud_username and app password
- OIDC callback defaults nextcloud_username from preferred_username on
  first login; backfill on existing users; never overwrites a manual
  override
- routers/upload.py: stream upload to NamedTemporaryFile, then PUT to
  WebDAV (with MKCOL chain) when destination is NC-rooted; existing
  Photo row creation runs unchanged
- routers/discard.py empty-trash: WebDAV DELETE for NC files
- routers/photos.py rename + move: WebDAV MOVE for NC paths;
  cross-system move/copy returns a clean error
- routers/folders.py rename + create + permanent-delete: dispatch via
  WebDAV when targeting NC-rooted paths

Frontend:
- AuthUser carries nextcloud_username + has_nextcloud_app_password
- services/api.ts: nextcloud + account namespaces
- components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name
  + submit -> POST /source-roots
- SettingsDialog: new "Nextcloud library" card with username override +
  validate, app-password input, list/remove of NC libraries, and the
  picker entry point

docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users
on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-04-26 01:06:37 +02:00
parent 80dd9d0a8b
commit bc0bb44c05
16 changed files with 1635 additions and 48 deletions

View File

@@ -0,0 +1,212 @@
/**
* NextcloudFolderPicker — modal that lets the user navigate their
* Nextcloud `files/` tree and register a subfolder as a SourceRoot in
* mule-image. Lazy-loads each level via GET /nextcloud/browse?path=...,
* so opening the picker doesn't slurp the whole tree.
*
* Path scoping is enforced server-side; we still avoid showing a `..`
* affordance above the user's root so the UI never surfaces the idea
* that there's something to escape to.
*/
import { useCallback, useEffect, useState } from 'react'
import { ChevronRight, Folder, FolderOpen, Loader2, X } from 'lucide-react'
import { nextcloud, type NextcloudBrowseEntry } from '../../services/api'
import { toast } from '../ToastContainer'
interface Props {
open: boolean
onClose: () => void
onCreated: () => void // called after a successful POST so the parent refetches
}
export function NextcloudFolderPicker({ open, onClose, onCreated }: Props) {
const [path, setPath] = useState('')
const [entries, setEntries] = useState<NextcloudBrowseEntry[]>([])
const [parentRel, setParentRel] = useState<string | null>(null)
const [ncUser, setNcUser] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [name, setName] = useState('')
const loadPath = useCallback(async (target: string) => {
setLoading(true)
setError(null)
try {
const r = await nextcloud.browse(target)
setEntries(r.entries)
setParentRel(r.parent_rel)
setNcUser(r.nc_username)
setPath(r.rel_path)
// Default the SourceRoot name from the leaf folder name.
const leaf = r.rel_path.split('/').filter(Boolean).slice(-1)[0]
if (leaf) setName(leaf)
else setName(`${r.nc_username} library`)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setError(message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) {
void loadPath('')
}
}, [open, loadPath])
const submit = useCallback(async () => {
if (!path) {
toast.error('Pick a subfolder first — the root is too broad.')
return
}
if (!name.trim()) {
toast.error('Library name is required')
return
}
setSubmitting(true)
try {
await nextcloud.createSourceRoot(name.trim(), path)
toast.success(`Added "${name.trim()}" to your libraries`)
onCreated()
onClose()
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
toast.error(`Could not add library: ${message}`)
} finally {
setSubmitting(false)
}
}, [path, name, onCreated, onClose])
if (!open) return null
// Breadcrumb segments for the current path. "/" rendered as the user's
// root → click jumps back to that level.
const segments = path ? path.split('/').filter(Boolean) : []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="flex h-[36rem] w-[40rem] flex-col rounded-lg border border-border bg-surface shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FolderOpen className="h-4 w-4" />
Add library from Nextcloud
</div>
<button
onClick={onClose}
className="rounded p-1 text-text-muted hover:bg-surface-2"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 border-b border-border bg-surface-2 px-4 py-2 text-xs text-text-muted">
<button
onClick={() => loadPath('')}
className="font-mono text-text hover:underline"
disabled={loading}
>
{ncUser || 'home'}
</button>
{segments.map((seg, i) => {
const target = segments.slice(0, i + 1).join('/')
return (
<span key={target} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
onClick={() => loadPath(target)}
className="font-mono text-text hover:underline"
disabled={loading}
>
{seg}
</button>
</span>
)
})}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="flex h-full items-center justify-center text-xs text-text-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading
</div>
)}
{error && !loading && (
<div className="rounded border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-300">
{error}
</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-text-muted">
No subfolders here. Use Nextcloud to create a folder, then come
back to register it.
</div>
)}
{!loading && !error && entries.length > 0 && (
<ul className="space-y-0.5">
{/* "Up one level" — only shown when not already at root */}
{parentRel !== null && (
<li>
<button
onClick={() => loadPath(parentRel)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
>
<ChevronRight className="h-4 w-4 rotate-180 text-text-muted" />
<span className="text-text-muted">..</span>
</button>
</li>
)}
{entries.map((e) => (
<li key={e.path}>
<button
onClick={() => loadPath(e.path)}
disabled={!e.has_children && false}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
>
<Folder className="h-4 w-4 text-primary/70" />
<span className="truncate">{e.name}</span>
{e.has_children && (
<ChevronRight className="ml-auto h-3 w-3 text-text-muted" />
)}
</button>
</li>
))}
</ul>
)}
</div>
{/* Footer */}
<div className="border-t border-border bg-surface-2 px-4 py-3">
<div className="mb-2 text-[11px] uppercase tracking-wide text-text-muted">
Selected
</div>
<div className="mb-2 truncate font-mono text-xs" title={path || '(root)'}>
{path ? `${ncUser}/files/${path}` : '(pick a subfolder)'}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Library name"
className="flex-1 rounded border border-border bg-surface px-2 py-1 text-sm"
/>
<button
onClick={() => void submit()}
disabled={!path || !name.trim() || submitting}
className="rounded bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Adding…' : 'Add this folder'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -10,6 +10,7 @@ import {
Cpu,
AlertCircle,
CheckCircle2,
Cloud,
Copy,
Sparkles,
Activity,
@@ -17,21 +18,26 @@ import {
Shield,
Brain,
RotateCcw,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import {
library,
admin as adminApi,
account as accountApi,
nextcloud as nextcloudApi,
type MediaType,
type PipelineStage,
type ScanStatus,
type WorkerStatus,
type FeatureFlagSnapshot,
type NextcloudSourceRoot,
} from '../../services/api'
import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext'
import { UserManagement } from '../admin/UserManagement'
import { NextcloudFolderPicker } from './NextcloudFolderPicker'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
@@ -323,6 +329,8 @@ export function SettingsPage() {
</div>
</Section>
<NextcloudIntegrationCard />
<Section
icon={<Activity className="h-4 w-4" />}
title="Pipeline progress"
@@ -1277,3 +1285,282 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
</>
)
}
// ── Nextcloud integration card ─────────────────────────────────────────
//
// Lives inside the Library tab. Lets the user (a) review/override their
// Nextcloud username, (b) store the Nextcloud app password used for
// outgoing WebDAV mutations, and (c) browse + register subfolders of
// their Nextcloud `files/` tree as per-user SourceRoots.
//
// Username defaults to the OIDC `preferred_username` claim on first
// login; the override is here for cases where the local mule-image user
// doesn't match the Nextcloud user (e.g. authentik `dtoro` vs Nextcloud
// `admin`). The app password is set from Nextcloud → Settings →
// Security → App passwords; we encrypt at rest and never echo it back.
const NEXTCLOUD_ROOTS_KEY = ['settings', 'nextcloud', 'source-roots'] as const
function NextcloudIntegrationCard() {
const { user } = useAuth()
const queryClient = useQueryClient()
const [pickerOpen, setPickerOpen] = useState(false)
const [usernameInput, setUsernameInput] = useState('')
const [passwordInput, setPasswordInput] = useState('')
const [whoamiState, setWhoamiState] = useState<
{ state: 'idle' } | { state: 'checking' } | { state: 'ok' } | { state: 'bad'; reason: string }
>({ state: 'idle' })
// Initialize the editable fields from the live user.
useEffect(() => {
if (user) {
setUsernameInput(user.nextcloud_username ?? '')
}
}, [user?.id, user?.nextcloud_username])
const rootsQuery = useQuery({
queryKey: NEXTCLOUD_ROOTS_KEY,
queryFn: () => nextcloudApi.listSourceRoots(),
staleTime: 30_000,
})
const validate = useCallback(async () => {
const candidate = usernameInput.trim()
if (!candidate) {
setWhoamiState({ state: 'bad', reason: 'no_username' })
return
}
setWhoamiState({ state: 'checking' })
try {
const r = await nextcloudApi.whoami(candidate)
setWhoamiState(r.valid ? { state: 'ok' } : { state: 'bad', reason: r.reason ?? 'invalid' })
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setWhoamiState({ state: 'bad', reason: message })
}
}, [usernameInput])
const saveSettings = useMutation({
mutationFn: async (body: { nextcloud_username?: string; nextcloud_app_password?: string }) =>
accountApi.updateMe(body),
onSuccess: () => {
toast.success('Nextcloud settings updated')
// Force AuthContext to refetch so the new flags propagate; the
// /auth/me endpoint backs both this and the AuthContext.
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
// Reload page to pick up the new auth user object — cheaper than
// wiring a refresh function through context for a one-off save.
window.setTimeout(() => window.location.reload(), 600)
},
onError: (e) => {
const message = e instanceof Error ? e.message : String(e)
toast.error(`Save failed: ${message}`)
},
})
const removeRoot = useMutation({
mutationFn: async (id: string) => nextcloudApi.deleteSourceRoot(id),
onSuccess: () => {
toast.success('Nextcloud library removed')
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
},
onError: (e) => {
const message = e instanceof Error ? e.message : String(e)
toast.error(`Could not remove: ${message}`)
},
})
const roots = rootsQuery.data ?? []
const hasPw = user?.has_nextcloud_app_password ?? false
const usernameDirty = usernameInput.trim() !== (user?.nextcloud_username ?? '')
return (
<Section
icon={<Cloud className="h-4 w-4" />}
title="Nextcloud library"
>
<p className="text-xs text-text-muted">
Browse subfolders of your Nextcloud <code>files/</code> tree and
register them as photo libraries. Reads stay on the filesystem
(fast); uploads, deletes, renames, and moves dispatch through
Nextcloud's WebDAV so its database, trashbin, comments, and sync
clients stay in sync.
</p>
{/* Username override */}
<div className="mt-3">
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
Nextcloud username
</label>
<div className="mt-1 flex items-center gap-2">
<input
type="text"
value={usernameInput}
onChange={(e) => {
setUsernameInput(e.target.value)
setWhoamiState({ state: 'idle' })
}}
placeholder="e.g. admin"
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
/>
<Button
size="sm"
variant="outline"
onClick={() => void validate()}
disabled={whoamiState.state === 'checking'}
>
{whoamiState.state === 'checking' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : whoamiState.state === 'ok' ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
'Validate'
)}
</Button>
<Button
size="sm"
disabled={!usernameDirty || saveSettings.isPending}
onClick={() =>
saveSettings.mutate({ nextcloud_username: usernameInput.trim() })
}
>
Save
</Button>
</div>
{whoamiState.state === 'bad' && (
<div className="mt-1 flex items-center gap-1 text-[11px] text-red-400">
<AlertCircle className="h-3 w-3" />
{whoamiState.reason === 'no_files_dir'
? 'No files/ directory for that user on the mounted Nextcloud volume.'
: whoamiState.reason === 'no_username'
? 'Enter a username first.'
: whoamiState.reason}
</div>
)}
{whoamiState.state === 'ok' && (
<div className="mt-1 flex items-center gap-1 text-[11px] text-green-400">
<CheckCircle2 className="h-3 w-3" /> Found a Nextcloud files/ directory.
</div>
)}
</div>
{/* App password */}
<div className="mt-3">
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
Nextcloud app password{' '}
{hasPw && <span className="text-green-400">(set)</span>}
</label>
<div className="mt-1 flex items-center gap-2">
<input
type="password"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
placeholder={hasPw ? ' (leave blank to keep)' : 'Generate in Nextcloud Settings Security'}
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
/>
<Button
size="sm"
disabled={saveSettings.isPending || (passwordInput.length === 0 && !hasPw)}
onClick={() => {
if (passwordInput.length > 0) {
saveSettings.mutate({ nextcloud_app_password: passwordInput })
setPasswordInput('')
}
}}
>
Save
</Button>
{hasPw && (
<Button
size="sm"
variant="ghost"
disabled={saveSettings.isPending}
onClick={() => saveSettings.mutate({ nextcloud_app_password: '' })}
title="Clear stored password"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<p className="mt-1 text-[11px] text-text-muted">
Used as HTTP Basic auth on outgoing WebDAV calls. Stored
encrypted at rest with a key derived from the backend{' '}
<code>SECRET_KEY</code>; the cleartext is never logged.
</p>
</div>
{/* SourceRoot list + Add button */}
<div className="mt-4">
<div className="flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-text-muted">
Nextcloud-rooted libraries
</div>
<Button
size="sm"
disabled={!user?.nextcloud_username}
onClick={() => setPickerOpen(true)}
>
<Cloud className="mr-1 h-3 w-3" />
Add from Nextcloud
</Button>
</div>
{!user?.nextcloud_username && (
<p className="mt-1 text-[11px] text-text-muted">
Set + save your Nextcloud username above before adding libraries.
</p>
)}
{rootsQuery.isLoading && (
<div className="mt-2 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
</div>
)}
{!rootsQuery.isLoading && roots.length === 0 && (
<p className="mt-2 text-xs text-text-muted">
No Nextcloud libraries yet. Click "Add from Nextcloud" once
you've validated your username and stored an app password.
</p>
)}
{roots.length > 0 && (
<ul className="mt-2 space-y-1">
{roots.map((r: NextcloudSourceRoot) => (
<li
key={r.id}
className="flex items-center gap-2 rounded border border-border bg-surface px-2 py-1 text-xs"
>
<Cloud className="h-3 w-3 text-primary/70" />
<div className="flex-1 truncate">
<div className="font-medium text-text">{r.name}</div>
<div className="truncate font-mono text-text-muted" title={r.path}>
{r.path}
</div>
</div>
{!r.is_active && (
<span className="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] text-amber-400">
inactive
</span>
)}
<Button
size="sm"
variant="ghost"
disabled={removeRoot.isPending}
onClick={() => removeRoot.mutate(r.id)}
title="Remove this library"
>
<Trash2 className="h-3 w-3" />
</Button>
</li>
))}
</ul>
)}
</div>
<NextcloudFolderPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onCreated={() => {
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
}}
/>
</Section>
)
}

View File

@@ -16,6 +16,11 @@ export interface AuthUser {
is_active: boolean
avatar_url: string | null
display_name: string | null
// Nextcloud integration. `nextcloud_username` is the override (defaults
// to OIDC preferred_username on first login). The boolean flag is
// server-side only — the cleartext password never reaches the client.
nextcloud_username: string | null
has_nextcloud_app_password: boolean
}
interface AuthContextValue {

View File

@@ -1108,4 +1108,89 @@ export const features = {
},
}
// ── Nextcloud integration ──────────────────────────────────────────────
//
// Browse the user's Nextcloud `files/` tree, register subfolders as
// per-user SourceRoots, and validate the username override. Path
// scoping is enforced server-side; all paths in/out are relative to
// the user's NC `files/` root.
export interface NextcloudBrowseEntry {
name: string
path: string
has_children: boolean
}
export interface NextcloudBrowseResponse {
nc_username: string
rel_path: string
parent_rel: string | null
entries: NextcloudBrowseEntry[]
}
export interface NextcloudWhoamiResponse {
configured: boolean
candidate: string | null
valid: boolean
reason: string | null
}
export interface NextcloudSourceRoot {
id: string
name: string
path: string
is_active: boolean
is_nextcloud: true
user_id?: string
}
export const nextcloud = {
whoami: async (candidate?: string): Promise<NextcloudWhoamiResponse> => {
const response = await api.get('/nextcloud/whoami', {
params: candidate ? { candidate } : undefined,
})
return response.data
},
browse: async (path: string = ''): Promise<NextcloudBrowseResponse> => {
const response = await api.get('/nextcloud/browse', {
params: { path },
})
return response.data
},
listSourceRoots: async (): Promise<NextcloudSourceRoot[]> => {
const response = await api.get('/nextcloud/source-roots')
return response.data
},
createSourceRoot: async (
name: string,
nextcloudPath: string,
): Promise<NextcloudSourceRoot> => {
const response = await api.post('/nextcloud/source-roots', {
name,
nextcloud_path: nextcloudPath,
})
return response.data
},
deleteSourceRoot: async (id: string): Promise<void> => {
await api.delete(`/nextcloud/source-roots/${id}`)
},
}
// ── Account self-service (Nextcloud creds + username override) ─────────
export const account = {
/** Update the current user's Nextcloud integration settings. Pass an
* empty string for `nextcloud_app_password` to clear it. */
updateMe: async (body: {
nextcloud_username?: string
nextcloud_app_password?: string
}) => {
const response = await api.patch('/auth/me', body)
return response.data
},
}
export default api