This file provides guidance to coding agents (Claude Code, Codex, Cursor, Aider, …) working with this repository. Claude Code reads it via @AGENTS.md in CLAUDE.md.
This project is split into two parts:
/src— OxiCloud Backend server in Rust/frontend— OxiCloud Frontend: a SvelteKit (Svelte 5) + TypeScript single-page app built with Vite
The original vanilla-JS/CSS frontend still lives in
/staticand is retained during the migration, but new frontend work goes in/frontend. Vite builds the SvelteKit app tostatic-dist/, which the Rust web layer serves in release.
cargo build # Dev build
cargo build --release # Optimized release build
cargo run # Run server (port 8086)
cargo test --workspace # Run all tests (~208)
cargo test <test_name> # Run a single test by name
cargo test --features test_utils # Run tests that use mockall mocks
cargo clippy -- -D warnings # Lint (zero warnings policy)
cargo fmt --all --check # Format check
cargo fmt --all # Auto-format
RUST_LOG=debug cargo run # Run with debug logging
cargo run --bin generate-openapi # Regenerate resources/gen/openapi.jsonA justfile is available for common tasks (just --list to see all). Key recipes: just check (fmt + clippy), just test, just openapi.
Requires Rust 1.93+ (edition 2024) and PostgreSQL 13+ (with pg_trgm and ltree extensions).
Database setup: docker compose up -d postgres — schema is applied automatically via sqlx migrations on app startup. Migration files live in migrations/. For local dev, set DATABASE_URL in .env (see example.env).
Always run these before committing, in this order:
cargo fmt --all # Auto-format
cargo clippy --all-features --all-targets -- -D warnings # Lint (must pass with zero warnings)CI enforces both — commits that fail either check will not merge.
When the change touches server code (anything under src/, migrations/,
Cargo.toml, or tests/), run the full suite locally before pushing — CI
is slower and a red CI run after a public push wastes maintainer attention:
just check # cargo fmt --check + cargo clippy -D warnings
just test # cargo test --workspace
just test-integration # cargo test --tests with integration cfg
just api-test # Hurl API + WebDAV scenariosRun them in that order — just check is fastest and catches the most
common issues first. Don't push if any step fails; investigate locally.
Hexagonal / Clean Architecture with four layers. Dependencies point inward only.
-
domain/— Core business entities (entities/) and repository trait definitions (repositories/). Pure Rust, no framework dependencies. Entity types:File,Folder,User,Calendar,CalendarEvent,Contact,Share,TrashedItem,Session,DeviceCode,AppPassword. -
application/— Use cases and orchestration.ports/— Trait definitions (inbound/outbound) for storage, auth, caching, compression, dedup, thumbnails, chunked uploads, CalDAV/CardDAV, etc. This is the hexagonal "ports" layer.services/— Use case implementations (FileManagementService,FolderService,ShareService,TrashService,CalendarService,ContactService,SearchService,BatchOperations, etc.).adapters/— CalDAV/CardDAV protocol adapters (iCalendar/vCard parsing).dtos/— Data transfer objects for API boundaries.
-
infrastructure/— Concrete implementations of ports.repositories/pg/— All PostgreSQL repository implementations (viasqlx). Usesauthschema for users/sessions,storageschema for files/folders/blobs (content-addressable dedup with ltree paths).services/— JWT, password hashing (Argon2), OIDC, compression, thumbnails, chunked uploads, WOPI discovery, WebDAV locking, file content caching (moka).adapters/— CalDAV/CardDAV storage adapters bridging domain traits to PG.db.rs— Dual connection pool setup (user pool + maintenance pool).
-
interfaces/— HTTP layer (Axum).api/handlers/— REST API handlers for files, folders, auth, admin, search, shares, WebDAV, CalDAV, CardDAV, WOPI, chunked uploads, batch operations.api/routes.rs— Route registration, splits protected vs public routes.nextcloud/— NextCloud-compatible API (WebDAV, OCS, login flow v2, trashbin) with Basic Auth middleware.middleware/— Auth (JWT validation), CSRF, rate limiting.web/— Static file serving.
-
common/— Cross-cutting concerns.di.rs—AppServiceFactorybuilds all services and producesAppState(the central DI container passed to Axum). This is the composition root.config.rs—AppConfig::from_env()loads allOXICLOUD_*env vars.
-
DI via
AppState: All services areArc-wrapped and assembled incommon/di.rs.AppStateis wrapped inArcand passed as Axum state. Many services areOption<Arc<T>>because they depend on features being enabled (auth, WOPI, trash, etc.). -
Content-addressable storage: Files use BLAKE3 blob dedup.
storage.file_blobsstores content;storage.file_metadatareferences blobs with ref-counting. Seefile_blob_write_repository.rsandfile_blob_read_repository.rs. -
ltree paths: Folder hierarchy uses PostgreSQL
ltreefor efficient subtree queries (recursive copies, moves, searches). -
Dual DB pools:
DbPoolsininfrastructure/db.rsseparates user-facing queries from maintenance/background tasks to prevent starvation. -
Feature flags: Major features (auth, trash, search, sharing, quotas) are toggled via
OXICLOUD_ENABLE_*env vars inFeaturesConfig. -
UUID columns: All ID columns use native PostgreSQL
UUIDtype. SQL queries must use::uuidcasts when passing string parameters to UUID columns.
authschema:users,sessions,app_passwords,device_codes,admin_settingsstorageschema:folders,file_metadata,file_blobs,trash,shares,favorites,recent_items,nextcloud_object_idscaldavschema:calendars,calendar_eventscarddavschema:address_books,contacts,contact_groups,contact_group_members
Schema definition: migrations/ (sqlx migrations, applied on startup)
The server exposes multiple protocol interfaces simultaneously:
- REST API under
/api/ - WebDAV at
/webdav/(RFC 4918) - CalDAV at
/caldav/ - CardDAV at
/carddav/ - NextCloud-compatible API at
/remote.php/,/ocs/,/status.php - WOPI at
/wopi/(when enabled) - Well-known discovery at
/.well-known/caldavand/.well-known/carddav
Tests are primarily #[cfg(test)] modules within source files (~36 files have inline tests). Dedicated test files exist at *_test.rs alongside their source. The test_utils feature flag enables mockall mock generation for trait-heavy testing. No separate tests/ directory.
Never duplicate logic across handlers or services. If the same behaviour is needed in more than one place, extract it into a shared function, method, or service before writing the second callsite. Preferred homes by layer:
- Cross-handler request logic → method on
CoreServicesorAppState(common/di.rs) - Reusable infrastructure behaviour → method on the relevant service struct
- Shared port behaviour → default method on the trait
AuthZ is enforced exclusively in the application service layer, never in handlers. All permission checks go through AuthorizationEngine (port: application/ports/authorization_ports.rs) via service methods named with the _with_perms suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass caller_id into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed.
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take caller_id: Uuid and call authz.require(...) before any read or mutation.
Every permission denial or auth rejection MUST emit a structured audit log line before returning the error. Without one, security-relevant outcomes are invisible to operators and incident response loses its primary signal.
The convention:
tracing::info!(
target: "audit",
event = "<domain>.<outcome>", // e.g. "authz.denied", "auth.login_rejected",
// "magic_link.redemption_rejected",
// "user_profile.rejected"
reason = "<short_key>", // stable machine-readable key for filtering
// (e.g. "bad_password", "expired", "no_visibility_path")
// …structured fields naming the actors / targets…
caller_id = %caller_id, // or subject_id, user_id, granted_by, etc.
target_id = %target_id, // or resource_id, subject_id, etc.
"👮🏻♂️ human-readable message: …", // helpful for live tailing, do not parse
);Rules:
target: "audit"routes the line to the audit channel (separable from operationaloxicloud::*debug noise).eventuses the dotted form<domain>.<verb_past_tense>and stays stable — log aggregators key off it.reasonis a machine-readable enum-style key. Don't reword across releases. New denial cause → newreasonvalue, never repurpose an existing one.- Structured fields carry every actor/target involved (
caller_id,target_id,resource_id,subject_id, role, is_external flag, etc.). Request id and client IP come from the request-scope span automatically — don't duplicate them. - Anti-enumeration is preserved. Returning
NotFoundto the caller while logging the real reason internally is the canonical pattern (e.g.user_profile.rejectedwithreason = "external_caller_no_relationship"returns 404, never 403). Operators see the truth; the attacker sees the same response shape regardless of whether the user exists. - Success paths stay quiet by default — every authorized request would otherwise flood the log. Use
tracing::debug!withtarget: "oxicloud::authz"(or similar) when a low-volume granted-trace helps debugging. Reservetracing::info!(target: "audit", …)for outcomes worth surfacing in security reviews.
Canonical examples to mirror: authz.denied in application/ports/authorization_ports.rs::require, auth.login_rejected and magic_link.redemption_rejected and user_profile.rejected in application/services/auth_application_service.rs.
The frontend is a SvelteKit single-page app (Svelte 5 + TypeScript, Vite,
adapter-static) under frontend/. Vite builds it to static-dist/, which the
Rust web layer serves in release (unmatched client routes fall back to the SPA
shell); PROFILE=dev serves the unbuilt source. The legacy vanilla frontend in
static/ is retained for now but is not where new work goes.
Run from frontend/ (or via the fe-* justfile recipes from the repo root):
npm ci # install deps (just fe-install)
npm run dev # Vite dev server + HMR (just fe-dev) — backend must run on :8086
npm run build # build the SPA → static-dist/ (just fe-build)
npm run check # svelte-check + ESLint + Stylelint + Prettier (just fe-check)
npm run test:unit # Vitest (just fe-test)
npm run format # prettier --write .just dev runs the backend and the Vite dev server together. CI uses Node 26; Node 24+ works locally.
routes/— SvelteKit pages (+page.svelte,+layout.svelte), one folder per route (files/[...path],photos,shared,trash,admin,s/[token], …).lib/components/— reusable Svelte components (AppShell,PhotoLightbox,ShareDialog,Modal, …).lib/api/— HTTP layer:client.ts(apiFetch/apiJson),csrf.ts(getCsrfHeaders),types.ts(API DTO types — map the backend here), andendpoints/*.ts(one module per area: files, folders, photos, people, grants, …).lib/stores/— global reactive state as*.svelte.tsrune stores (session,ui,theme,dialogs).lib/composables/— reusable rune logic (useSelection,useOwnerCache).lib/i18n/— bespoke reactive i18n;t(key, [params], fallback)readsfrontend/static/locales/*.json(16 locales) with{{param}}interpolation and an English fallback.lib/icons/—Icon.svelte+ a generated Font Awesomeregistry.ts.lib/utils/,lib/vendor/— shared helpers and minimal typings/loaders for vendored libs.lib/styles/— global CSS (app.css,base/,ported/).static/— served at the web root:locales/,vendors/(maplibre-gl, pmtiles, hash-wasm),workers/(deltaWorker), optionalbasemaps/.
- Svelte 5 runes —
$state,$derived,$props,$effect,$bindable. No legacyexport letfor new components. - TypeScript everywhere (
lang="ts"in components). Noany—typescript-eslintrecommended is enforced; prefer precise types,unknown+ narrowing, or a minimal declared interface for an untyped global (seelib/vendor/maplibre.ts). - ES Modules;
camelCasefor variables/functions,PascalCasefor components/classes;const/let, nevervar. - API DTO shapes live in
lib/api/types.ts; call the backend throughlib/api/endpoints/*— don't bare-fetch/apifrom components.
Never duplicate logic across modules/components. Extract shared behaviour:
- DOM/UI helpers →
lib/utils/ - API wrappers → the relevant
lib/api/endpoints/*module - Cross-component state/logic → a
lib/stores/*.svelte.tsstore or alib/composables/* - Shared markup → a component (e.g.
PhotoLightboxis shared by the photos grid, People and Places)
- BEM methodology for class names (
.block__element--modifier). - Component styles live in the component's scoped
<style>; cross-cutting tokens/styles inlib/styles/. - All colors must use
var(--*)— no raw hex, rgb, or named colors (Stylelint enforcesfunction-disallowed-list); define tokens inlib/styles/base/variables.css. - Mobile-first: media queries expand, they don't restrict.
- Dark mode keys off
<html data-color-scheme="dark">.
Always run from frontend/ before committing:
npm run check # svelte-kit sync && svelte-check && eslint . && stylelint "src/**/*.{css,svelte}" && prettier --check .
npm run test:unit # VitestCI runs the same npm run check (plus Vitest) — commits that fail will not merge.
- Edit
Cargo.lockorfrontend/package-lock.jsonby hand - Introduce a different JS framework (React, Vue, etc.) — the frontend is SvelteKit/Svelte 5
- Add a heavy runtime npm dependency without discussion — prefer vendoring + lazy-loading under
frontend/static/vendors/(see maplibre-gl / pmtiles) - Use
anyin TypeScript - Leave debug
console.logstatements in code - Use raw color values in CSS — always use CSS custom properties
- Commit without passing all linters (
npm run checkfor the frontend;cargo fmt+cargo clippyfor the backend)