Commit Graph

504 Commits

Author SHA1 Message Date
Edouard Vanbelle 0fcd617fd1 feat(calendar,addressbook): migrate share to authz engine
migrate DB entries to authz engine
    and wire authz engine to caldav and carddav
2026-07-08 01:03:25 +02:00
Edouard Vanbelle b5881c7114 feat(calendar,addressbook): prepare authz engine 2026-07-08 01:03:25 +02:00
Edouard Vanbelle f7deb7aaf4 fix(authz): invalidate role cache on change 2026-07-06 23:07:49 +02:00
Edouard Vanbelle 79f4792709 fix(quota): pre-check quota for COPY/MOVE
check qouta for a cross drive MOVE
    check quota for a COPY
2026-07-06 22:14:50 +02:00
Edouard Vanbelle 1786fe4111 security(nextcloud): chroot-aware display paths + recent race fix
strip_chroot_prefix replaces the hardcoded "Personal/" strip
    in NC trashbin PROPFIND, OCS unified search, and REPORT
    (favorites + search). Handles composed chroots, drops
    cross-chroot items instead of surfacing malformed paths, and
    fixes the leading-slash mismatch (FolderDto path has '/', DB
    paths don't) that silently dropped every NC trashbin item
    post-D3. OCS keeps a first-segment fallback (results
    legitimately span drives, no single chroot).

    uploads_handler switches to nc_to_internal_path(chroot, …)
    for the two remaining hardcoded "Personal/" sites, closing
    the D1 TODO markers.

    RecentService::record_item_access is split from a new
    record_item_access_internal (no authz) used by
    RecentRecordingHook. Round 1's authz.require widened the
    tokio::spawn race past tests/api/recent.hurl step 7; the
    internal path skips the redundant Read gate — upstream
    _with_perms already enforced it.

    Tests: 8 unit tests pin strip_chroot_prefix (leading slash,
    composed chroots, sibling-leak rejection, partial-prefix,
    empty-chroot). drives_membership.hurl step 21b/22b cover
    Editor upload → 201 / Viewer upload → 404 fresh + overwrite
    with fixture cleanup at 30c. test_nc_move_copy_delete_trash
    K1 pins the actual original-location value.
2026-07-05 22:53:05 +02:00
Edouard Vanbelle 0342bae300 security(nextcloud): add authz to PUT verb 2026-07-05 22:53:05 +02:00
Edouard Vanbelle b95e740b2f security(music): ensure read permission via authz 2026-07-05 22:53:05 +02:00
Edouard Vanbelle 2cda8e7e22 security(favorite,recent): ensure read permission 2026-07-05 22:53:05 +02:00
Edouard Vanbelle cf5423b723 security(public link): require share permission
issue was: a user can reshare publicly a resource
    on owner revocation, the attacker keep it's own share
    request now share permission
2026-07-05 22:53:05 +02:00
Edouard Vanbelle f115fed5a6 feat(drive): cleanup of useless owner_id 2026-07-05 22:53:05 +02:00
Edouard Vanbelle 92d1a10d45 security(quick-xml): bump version to 0.41.0
- and protect amount of properties
 - azure_core 0.21.0 is using quick-xml 0.31.0  which is Dos-able
   azure_core is no more maintained, would migrte to official azure lib
   later
2026-07-03 02:12:36 +02:00
Edouard Vanbelle 37467ed9d3 feat(drive): remove all owner_id from {File,Folder}Dto 2026-07-03 01:13:53 +02:00
Edouard Vanbelle 206a780494 feat(drive): stop writing in {file,folder}.user_id
plan:

    ```
    What step 5 is actually doing

    Goal: stop writing to storage.files.user_id / storage.folders.user_id on new INSERTs. This is a prerequisite for step 6 (dropping the column entirely). The column has existed since the pre-D0 single-owner era; every read that used to key on it has already been migrated to drive-membership grants over the last N days.

    What has to change together for this to be safe:

    ┌──────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────┐
    │    Piece     │                                                          What changes                                                           │                                                  Why                                                  │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ Swap storage.files uniqueness indexes from (folder_id, name, user_id) to (drive_id, folder_id, name)                            │ Otherwise, two new rows with user_id = NULL would both be allowed (PG treats NULLs as distinct) —     │
    │ (a)          │                                                                                                                                 │ uniqueness silently breaks                                                                            │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ ALTER user_id DROP NOT NULL on both tables                                                                                      │ Otherwise, dropping the INSERT bind violates NOT NULL and every write 500s                            │
    │ (b)          │                                                                                                                                 │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Migration    │ Drop dead user_id-leading indexes                                                                                               │ Cheap cleanup — nothing scans them anymore                                                            │
    │ (c)          │                                                                                                                                 │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ PL/pgSQL (d) │ Rewrite storage.copy_folder_tree without user_id in the INSERT column list                                                      │ Cross-drive copy runs entirely in SQL, needs the same treatment                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Rust (e)     │ ~12 INSERT column-list drops across folder_db_repository, file_blob_write_repository, drive_pg_repository, dedup_service,       │ Actual write path                                                                                     │
    │              │ folder_service, load-seed.rs                                                                                                    │                                                                                                       │
    ├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
    │ Rust (f)     │ Simplify folder_db_repository::create_folder's parent lookup to only fetch drive_id (was fetching (user_id, drive_id))          │ It's fetching a value it no longer needs                                                              │
    └──────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘
    ```
2026-07-03 00:23:35 +02:00
Edouard Vanbelle f5f5b1167f feat(drive): remove _for_owner() and use authz
remove related IDOR protection as Hurl tests are covering this surface
2026-07-02 01:14:02 +02:00
Edouard Vanbelle 09790644f3 feat(drive): repl user_id by caller has read access
repl user_id by caller has read access in readonly functions
    using CALLER_CAN_READ_DRIVE constant

    ensure webdav preview is using the permission handler
2026-07-02 01:03:39 +02:00
Edouard Vanbelle 79bc60899b feat(drive): improvepermission check and simplify functions 2026-07-02 00:29:33 +02:00
Edouard Vanbelle 858139ef3b feat(drive): prepare removal of user_id
this commit changes GET /api/<resources> to return resource caller has access to
    this is not anymmore resources users is owner of
2026-07-02 00:01:20 +02:00
M.Schmidt 0ad0ea1a43 fix(webdav): reject PROPPATCH on protected DAV:/oc:/nc:/ocs: props
DeadPropertyStore let PROPPATCH set any namespace/name verbatim,
incl. names the server itself emits as live state (DAV: entirely,
plus oc:/nc:/ocs: names used by write_file_response /
write_folder_response). That either forges a live prop or stores
dead rows nothing ever reads. is_protected_property() denylists
them; both PROPPATCH handlers (native + NC) now return per-property
403 instead of storing. oc:favorite stays writable via its existing
special-case, which runs before the protection check.
2026-07-01 22:54:48 +02:00
Edouard Vanbelle 01ff7dab0b feat(drive): impl policy photo + music policies
add `include_in_photo_index` and `include_in_music_index` policies
    both true for default personal drive

    photo is implemented
    music is not yet implemented
2026-07-01 21:57:37 +02:00
M.Schmidt f220217659 feat(nc-webdav): generic dead-props on PROPPATCH/PROPFIND/REPORT
NC PROPPATCH only special-cased oc:favorite, silently dropped every
other prop while claiming 200. PROPFIND had no dead-prop lookup at
all. Reuse native /webdav/ dead-prop plumbing (now pub(crate)) so NC
surface gets real RFC 4918 §4.2 storage. Missing resource on
PROPPATCH is now 404 instead of a fake success no-op.
2026-07-01 19:59:16 +02:00
M.Schmidt 6ca6649e50 fix(webdav): achieve 100% litmus compliance (59/59 tests)
Complete RFC 4918 litmus test suite compliance (basic 16/16,
copymove 13/13, props 30/30) by fixing six categories of failures:

- PropPatchOp: parse PROPPATCH in document order (RFC 4918 §9.2)
  so that remove-then-set and set-then-remove yield different results
- Null namespace: resolve_name now correctly maps xmlns="" to empty-ns
  QualifiedName instead of falling through to DAV: namespace
- Unicode chars: handle quick-xml 0.39's Event::GeneralRef for character
  references (&#65536;) — the parser emits these as GeneralRef, not Text
- MOVE preserves dead props: call rename_resource on MOVE, clearing
  stale destination data even when source has no registered properties
- Malformed PROPFIND: return 400 when body lacks a complete <propfind>
  element (test 2: bare <foo>, test 3: invalid xmlns:prefix="" binding)
- PROPPATCH document order: process ops via Vec<PropPatchOp> instead of
  separate (sets, removes) to honour interleaved remove/set sequences
2026-06-30 08:58:13 +02:00
M.Schmidt 1cf48b0dfa fix(webdav): RFC 4918 PROPFIND adapter compliance
- Return unknown properties in a separate 404 propstat (RFC 4918 §9.2)
  via folder_prop_is_known/file_prop_is_known classifiers
- Detect unclosed <propfind> element and return 400 Bad Request
- Add write_folder/file_entry_with_dead_props to include stored dead
  properties in PROPFIND responses
- Change write_folder/file_requested_props to accept &[&QualifiedName]
  to avoid clone overhead when using partition output
2026-06-28 23:51:38 +02:00
M.Schmidt b5afa1a855 feat(webdav): implement RFC 4918 dead property store
Add an in-memory DeadPropertyStore backed by RwLock<HashMap> that
stores arbitrary client-supplied XML properties per resource path.
Wire it into AppState so PROPPATCH can persist dead props and PROPFIND
can retrieve them across requests.
2026-06-26 21:19:03 +02:00
Bradley Nelson 07320a3925 feat(mounts): P4 — admin CRUD endpoints + frontend admin tab
Mounts are now configurable at runtime without DB surgery.

Backend (admin-gated, /api/admin/external-mounts):
- GET    list all configured mounts
- POST   create: validate the provider config up front, create a mount-root
         folder under the admin's drive, insert the row, hot-reload the registry
- DELETE remove the mount row + its root folder (host content is left intact),
         then hot-reload
- ExternalMountRepositoryPort gains create/delete (PG impl); registry.reload()
  runs in-process so changes are live immediately
- audit lines event="external_mount.config" action=create|delete

Frontend:
- admin.ts endpoints: listExternalMounts / createExternalMount / deleteExternalMount
- new "External Mounts" tab in the admin page: add form (name + host path +
  read-only) and a list with delete

Integration test for the repo create/delete round-trip (testcontainers).
2026-06-25 01:07:03 -06:00
Bradley Nelson d8229650ef feat(mounts): P3 — WebDAV/NextCloud path resolution + browse/download
Mounts are now browsable and downloadable over both WebDAV surfaces
(/webdav/ and NextCloud /remote.php/dav) and NextCloud clients. The work
funnels through the path-based service methods all three surfaces already use,
so both WebDAV handlers gain mount support with no handler changes.

- get_folder_by_path / get_file_by_path resolve a path that descends past a
  mount root to a synthetic ext: DTO (the mount root itself stays a real row)
- list_folders_paginated_with_perms / list_files_batch_with_perms branch to the
  provider, so PROPFIND Depth:1 enumerates mount directory contents in both
  WebDAV handlers
- get_file_stream / get_file_range_stream branch ext: file ids to the provider,
  so WebDAV GET streams mount content (Pin<Box<dyn Stream>> re-boxed)
- router injected into FileRetrievalService; new MountRouter::find_path delegates
  to the registry's (drive_id, mount_path) index
- WebDAV mkdir/delete/move/rename by path work via path→ext:id→the P2 service
  methods (no extra wiring)

Fixes a leading-slash normalization bug in the registry path index: materialized
folder paths arrive both as `Personal/Media` and `/Personal/Media`; keys and
lookups now normalize the leading slash (would have broken real WebDAV paths).

Known follow-up: WebDAV PUT (upload/update by path) still ingests to the CAS;
streaming a WebDAV PUT straight to the provider needs a pre-ingest branch in the
two WebDAV PUT handlers (mirrors the REST upload branch).

Integration test: get_folder_by_path/get_file_by_path resolution, PROPFIND
Depth:1 folder+file listing, and content streaming on a real mount + Postgres.
2026-06-25 00:51:16 -06:00
Bradley Nelson 8e3e31da4d feat(mounts): P2 — read-write REST for external mounts
Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/
services classify; authorization stays in the service via the mount-root
folder grant; the provider does the I/O).

- mkdir / rename / delete / move-within branch inside FolderService and
  FileManagementService (router injected into both)
- streaming upload via a new ExternalUploadService: the upload handler detects a
  mount destination BEFORE the CAS ingest and streams the multipart body
  straight to the provider (no BLAKE3/dedup). `write_stream` now takes a
  lifetime-bound boxed stream so the borrowing multipart field can be passed
  without buffering.
- deletes on mounts are permanent (no trash): the trash-first folder handler
  routes `ext:` ids straight to the provider delete; file delete goes through the
  branched delete_and_cleanup
- cross-backend move/copy (mount ↔ native, or between mounts) is forbidden
  (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted
- every mutation emits a `target:"audit" event="external_mount.write"` line
- shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat

Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete,
streaming upload, cross-boundary forbid, and stranger-denied — all against real
Postgres + a real provider (testcontainers).
2026-06-25 00:30:10 -06:00
Bradley Nelson 3c31695579 feat(mounts): external file mounts P1 — pluggable provider + read-only REST
Adds the foundation for external file mounts: admin-configured backends
(raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced
as a folder inside a user's drive. Mount contents are virtual/live-passthrough
— read straight from the backend, never stored in storage.files — and are a
deliberately separate, limited storage type (no dedup/sharing/trash/search).
The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false).

P1 scope (this PR): data model, the pluggable provider abstraction, and the
read-only REST surface (mount listing + download). Read-write (P2),
WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow.

Core model
- Mount root = a real storage.folders row; authorization for everything inside
  collapses onto that folder UUID (ltree-ancestry grant cascade).
- Children are virtual, addressed by ext:<mount_id>:<base64url(node_id)> where
  node_id is provider-owned and opaque to the rest of the system.
- A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin
  MountRouter::classify() is the single cheap hook handlers call before parsing
  an id as a UUID. With no mounts configured it always returns Regular, so
  existing code paths are unchanged.

Added
- migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB)
- domain/services/external_mount_id (id envelope + virtual etags)
- application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port)
- infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory
- application MountRegistry + MountRouter, pg ExternalMountRepository
- DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts
- listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and
  download branch (FileRetrievalService stat/open mount methods + file_handler)

Authorization stays in the service layer (authz.require(Resource::Folder(mount_id)));
handlers only classify. Cross-backend operations are out of scope for P1.

Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17),
including end-to-end authorization (owner allowed, stranger denied). Line
coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap:
file_handler::download_mount_file (HTTP glue) needs a full-app test (P4).
2026-06-24 23:52:01 -06:00
M.Schmidt 5913fff047 feat(webdav): RFC 4918 prop classification and 404 propstat 2026-06-24 23:08:51 +02:00
Edouard Vanbelle 754e6239da fix(drive): correct integration test 2026-06-24 08:02:40 +02:00
Edouard Vanbelle 289cf19270 feat(drive): user can rename drive
- only owners can rename root folders name (aka the drive name)
    - add UI to rename drive's name
2026-06-24 02:03:21 +02:00
Edouard Vanbelle 6034dd47d9 feat(drive): improve drive edition from owners 2026-06-24 02:03:17 +02:00
Edouard Vanbelle d77846119f feat(drive): UI: add drive edition for admin 2026-06-24 01:20:44 +02:00
Edouard Vanbelle a5b24a7453 feat(drive): add /api/drive
- permit shared drive creation from oxicloud admin (for now)
    - prepare other personal drive creation (Not implemented), need to validate
    quota policies and strategy first
    - add hurl test to verify permissions
2026-06-23 23:50:57 +02:00
Edouard Vanbelle 184520c17a feat(drive): plug trash to drives 2026-06-23 22:04:20 +02:00
Edouard Vanbelle 4eb9707774 feat(drive): check user grants on drive
use drive's grant whant handling a file or folder
2026-06-23 20:26:31 +02:00
Edouard Vanbelle 2fe9c9ffeb feat(drive): tell UI the caller role per Drive 2026-06-23 00:09:18 +02:00
Edouard Vanbelle a82bae4136 feat(drive): api to handle Drive grants 2026-06-22 23:53:00 +02:00
Edouard Vanbelle 583af517c2 fix(drive): fix drive_id on copy to other drive
- fix alsot drive_id exposition
2026-06-29 23:21:17 +02:00
Edouard Vanbelle ee92d365b9 feat(drive): ensure drive_id updated on file|folder moved to another drive 2026-06-29 21:11:40 +02:00
Edouard Vanbelle 66f2aaa250 feat(drive): add drive policie
- add policy forbid_external_sharing
    - add policy forbid_sharing
    - add polocy forbid_cross_drive_move
    - add policy forbid_owner_role_change
2026-06-26 14:04:36 +02:00
Edouard Vanbelle ddb131da8b feat(drive): add policy forbid_public_links 2026-06-26 13:59:02 +02:00
Edouard Vanbelle cfd783cbd3 feat(drive): personal drives have the user's quota in commun 2026-06-26 13:59:02 +02:00
Dionisio Pozo 996ace8a1d Merge pull request #524 from EdouardVanbelle/fix/cascading-deletion-and-file-life-cycle
fix(fileLifeCycle): add missing on_file_deleted() with cascading deletion
2026-06-26 12:35:25 +02:00
Edouard Vanbelle f4032e9e23 feat(recent): update recent list server side
initially the recent was done client side
    recent files are now directly updated on serverside when accessing a file

    note: nextcloud and webdav voluntary not included
2026-06-26 01:44:19 +02:00
Edouard Vanbelle fefba83cfc fix(fileLifeCycle): add missing on_file_deleted with cascading del
on_file_deleted() was missing on folder deletion or on cascading deletion
2026-06-25 23:36:50 +02:00
Edouard Vanbelle 2604ac3309 refactor: format syntaxt 2026-06-25 00:07:16 +02:00
Edouard Vanbelle 6b8e2ba49c test(drive): check quota calculus 2026-06-24 23:01:34 +02:00
Edouard Vanbelle a9a8604322 feat(drive): add quota calculus per drive
- quota per drive
    - add 2 internal API endpoints to test purpose
        disabled by default, enable it via `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`

        this enable:

            /api/admin/internal/trigger-sweep
                to sweep the trash and recalculated quota

            /api/admin/internal/trigger-gc
                to garbage orphan blobs

        use full for end to end tests and validate lifecycles
2026-06-24 22:28:31 +02:00
Edouard Vanbelle b73f176024 feat(drive): permanent deletion per drive 2026-06-24 21:56:27 +02:00
Edouard Vanbelle 7d24015fc4 feat(drive): add drive deletion
- conditions: drive must be empty
    - deletion forbidden on main personal drive
2026-06-24 21:17:27 +02:00