diff --git a/migrations/0032_webdav-class2-state.sql b/migrations/0032_webdav-class2-state.sql
new file mode 100644
index 00000000..0ea1ad63
--- /dev/null
+++ b/migrations/0032_webdav-class2-state.sql
@@ -0,0 +1,27 @@
+CREATE TABLE `webdav_dead_properties` (
+ `id` text PRIMARY KEY NOT NULL,
+ `org_id` text NOT NULL,
+ `resource_path` text NOT NULL,
+ `namespace` text NOT NULL,
+ `name` text NOT NULL,
+ `value` text NOT NULL,
+ `updated_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `webdav_dead_properties_resource_prop_uniq` ON `webdav_dead_properties` (`org_id`,`resource_path`,`namespace`,`name`);--> statement-breakpoint
+CREATE INDEX `webdav_dead_properties_resource_idx` ON `webdav_dead_properties` (`org_id`,`resource_path`);--> statement-breakpoint
+CREATE TABLE `webdav_locks` (
+ `id` text PRIMARY KEY NOT NULL,
+ `token` text NOT NULL,
+ `org_id` text NOT NULL,
+ `resource_path` text NOT NULL,
+ `owner` text DEFAULT '' NOT NULL,
+ `depth` text DEFAULT 'infinity' NOT NULL,
+ `expires_at` integer NOT NULL,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `webdav_locks_token_unique` ON `webdav_locks` (`token`);--> statement-breakpoint
+CREATE INDEX `webdav_locks_resource_idx` ON `webdav_locks` (`org_id`,`resource_path`);--> statement-breakpoint
+CREATE INDEX `webdav_locks_expires_idx` ON `webdav_locks` (`expires_at`);
\ No newline at end of file
diff --git a/migrations/meta/0032_snapshot.json b/migrations/meta/0032_snapshot.json
new file mode 100644
index 00000000..00f8dea1
--- /dev/null
+++ b/migrations/meta/0032_snapshot.json
@@ -0,0 +1,3027 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "eda12968-1f62-4f7a-a941-66f68da413aa",
+ "prevId": "c0b41ef0-1076-40cd-aeca-cbb1d7240062",
+ "tables": {
+ "activity_events": {
+ "name": "activity_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_name": {
+ "name": "target_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "announcements": {
+ "name": "announcements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'draft'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "announcements_status_priority_idx": {
+ "name": "announcements_status_priority_idx",
+ "columns": [
+ "status",
+ "priority"
+ ],
+ "isUnique": false
+ },
+ "announcements_published_idx": {
+ "name": "announcements_published_idx",
+ "columns": [
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "background_jobs": {
+ "name": "background_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target_folder": {
+ "name": "target_folder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_path": {
+ "name": "target_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "input_bytes": {
+ "name": "input_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_bytes": {
+ "name": "output_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processed_bytes": {
+ "name": "processed_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "file_count": {
+ "name": "file_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "current_filename": {
+ "name": "current_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result_metadata": {
+ "name": "result_metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retryable": {
+ "name": "retryable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "cancelable": {
+ "name": "cancelable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "retried_from_job_id": {
+ "name": "retried_from_job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "background_jobs_org_created_idx": {
+ "name": "background_jobs_org_created_idx",
+ "columns": [
+ "org_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "background_jobs_org_status_idx": {
+ "name": "background_jobs_org_status_idx",
+ "columns": [
+ "org_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "background_jobs_org_type_idx": {
+ "name": "background_jobs_org_type_idx",
+ "columns": [
+ "org_id",
+ "type"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cloud_traffic_reports": {
+ "name": "cloud_traffic_reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period": {
+ "name": "period",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "cloud_traffic_reports_event_uniq": {
+ "name": "cloud_traffic_reports_event_uniq",
+ "columns": [
+ "event_id"
+ ],
+ "isUnique": true
+ },
+ "cloud_traffic_reports_org_period_idx": {
+ "name": "cloud_traffic_reports_org_period_idx",
+ "columns": [
+ "org_id",
+ "period"
+ ],
+ "isUnique": false
+ },
+ "cloud_traffic_reports_status_idx": {
+ "name": "cloud_traffic_reports_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "image_hosting_configs": {
+ "name": "image_hosting_configs",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_domain": {
+ "name": "custom_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cf_hostname_id": {
+ "name": "cf_hostname_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "domain_verified_at": {
+ "name": "domain_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referer_allowlist": {
+ "name": "referer_allowlist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "image_hosting_configs_custom_domain_unique": {
+ "name": "image_hosting_configs_custom_domain_unique",
+ "columns": [
+ "custom_domain"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "image_hosting_configs_org_id_organization_id_fk": {
+ "name": "image_hosting_configs_org_id_organization_id_fk",
+ "tableFrom": "image_hosting_configs",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "image_hostings": {
+ "name": "image_hostings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "storage_id": {
+ "name": "storage_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mime": {
+ "name": "mime",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'draft'"
+ },
+ "access_count": {
+ "name": "access_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_accessed_at": {
+ "name": "last_accessed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "image_hostings_token_unique": {
+ "name": "image_hostings_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "image_hostings_org_path_uniq": {
+ "name": "image_hostings_org_path_uniq",
+ "columns": [
+ "org_id",
+ "path"
+ ],
+ "isUnique": true
+ },
+ "image_hostings_org_created_idx": {
+ "name": "image_hostings_org_created_idx",
+ "columns": [
+ "org_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "image_hostings_token_idx": {
+ "name": "image_hostings_token_idx",
+ "columns": [
+ "token"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "image_hostings_org_id_organization_id_fk": {
+ "name": "image_hostings_org_id_organization_id_fk",
+ "tableFrom": "image_hostings",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "image_hostings_storage_id_storages_id_fk": {
+ "name": "image_hostings_storage_id_storages_id_fk",
+ "tableFrom": "image_hostings",
+ "tableTo": "storages",
+ "columnsFrom": [
+ "storage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite_codes": {
+ "name": "invite_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_by": {
+ "name": "used_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_codes_code_unique": {
+ "name": "invite_codes_code_unique",
+ "columns": [
+ "code"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "license_bindings": {
+ "name": "license_bindings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cloud_binding_id": {
+ "name": "cloud_binding_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cloud_store_id": {
+ "name": "cloud_store_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cloud_account_id": {
+ "name": "cloud_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cloud_account_email": {
+ "name": "cloud_account_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cached_certificate": {
+ "name": "cached_certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cached_certificate_expires_at": {
+ "name": "cached_certificate_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bound_at": {
+ "name": "bound_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disconnected_at": {
+ "name": "disconnected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_refresh_at": {
+ "name": "last_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_refresh_error": {
+ "name": "last_refresh_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "license_bindings_active_uniq": {
+ "name": "license_bindings_active_uniq",
+ "columns": [
+ "status"
+ ],
+ "isUnique": true,
+ "where": "status = 'active'"
+ },
+ "license_bindings_cloud_binding_idx": {
+ "name": "license_bindings_cloud_binding_idx",
+ "columns": [
+ "cloud_binding_id"
+ ],
+ "isUnique": false
+ },
+ "license_bindings_instance_idx": {
+ "name": "license_bindings_instance_idx",
+ "columns": [
+ "instance_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "matters": {
+ "name": "matters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "alias": {
+ "name": "alias",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "dirtype": {
+ "name": "dirtype",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "parent": {
+ "name": "parent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "object": {
+ "name": "object",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "storage_id": {
+ "name": "storage_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'draft'"
+ },
+ "trashed_at": {
+ "name": "trashed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "matters_alias_unique": {
+ "name": "matters_alias_unique",
+ "columns": [
+ "alias"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "ref_type": {
+ "name": "ref_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ref_id": {
+ "name": "ref_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notifications_user_created_idx": {
+ "name": "notifications_user_created_idx",
+ "columns": [
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "notifications_user_read_idx": {
+ "name": "notifications_user_read_idx",
+ "columns": [
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "org_quota_entitlements": {
+ "name": "org_quota_entitlements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource_type": {
+ "name": "resource_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_quota_entitlements_org_resource_idx": {
+ "name": "org_quota_entitlements_org_resource_idx",
+ "columns": [
+ "org_id",
+ "resource_type",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "org_quota_entitlements_source_resource_uniq": {
+ "name": "org_quota_entitlements_source_resource_uniq",
+ "columns": [
+ "source",
+ "source_id",
+ "resource_type"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "org_quotas": {
+ "name": "org_quotas",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quota": {
+ "name": "quota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "used": {
+ "name": "used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "traffic_quota": {
+ "name": "traffic_quota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "traffic_used": {
+ "name": "traffic_used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "traffic_period": {
+ "name": "traffic_period",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1970-01'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "share_recipients": {
+ "name": "share_recipients",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "share_id": {
+ "name": "share_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_user_id": {
+ "name": "recipient_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "share_recipients_share_id_idx": {
+ "name": "share_recipients_share_id_idx",
+ "columns": [
+ "share_id"
+ ],
+ "isUnique": false
+ },
+ "share_recipients_user_id_idx": {
+ "name": "share_recipients_user_id_idx",
+ "columns": [
+ "recipient_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "shares": {
+ "name": "shares",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "matter_id": {
+ "name": "matter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_limit": {
+ "name": "download_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "downloads": {
+ "name": "downloads",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "shares_token_unique": {
+ "name": "shares_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "shares_creator_status_created_idx": {
+ "name": "shares_creator_status_created_idx",
+ "columns": [
+ "creator_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "site_invitations": {
+ "name": "site_invitations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accepted_by": {
+ "name": "accepted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accepted_at": {
+ "name": "accepted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "site_invitations_token_unique": {
+ "name": "site_invitations_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "site_invitations_email_idx": {
+ "name": "site_invitations_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ },
+ "site_invitations_created_idx": {
+ "name": "site_invitations_created_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "site_invitations_expires_idx": {
+ "name": "site_invitations_expires_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "storages": {
+ "name": "storages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'auto'"
+ },
+ "access_key": {
+ "name": "access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secret_key": {
+ "name": "secret_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "custom_host": {
+ "name": "custom_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "capacity": {
+ "name": "capacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "used": {
+ "name": "used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_options": {
+ "name": "system_options",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "team_invite_links": {
+ "name": "team_invite_links",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "team_invite_links_token_unique": {
+ "name": "team_invite_links_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webdav_dead_properties": {
+ "name": "webdav_dead_properties",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource_path": {
+ "name": "resource_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webdav_dead_properties_resource_prop_uniq": {
+ "name": "webdav_dead_properties_resource_prop_uniq",
+ "columns": [
+ "org_id",
+ "resource_path",
+ "namespace",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "webdav_dead_properties_resource_idx": {
+ "name": "webdav_dead_properties_resource_idx",
+ "columns": [
+ "org_id",
+ "resource_path"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webdav_locks": {
+ "name": "webdav_locks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource_path": {
+ "name": "resource_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "depth": {
+ "name": "depth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'infinity'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webdav_locks_token_unique": {
+ "name": "webdav_locks_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "webdav_locks_resource_idx": {
+ "name": "webdav_locks_resource_idx",
+ "columns": [
+ "org_id",
+ "resource_path"
+ ],
+ "isUnique": false
+ },
+ "webdav_locks_expires_idx": {
+ "name": "webdav_locks_expires_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhook_events": {
+ "name": "webhook_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cloud'"
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'order.quota_changed'"
+ },
+ "payload_hash": {
+ "name": "payload_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "raw_payload": {
+ "name": "raw_payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhook_events_source_event_uniq": {
+ "name": "webhook_events_source_event_uniq",
+ "columns": [
+ "source",
+ "event_id"
+ ],
+ "isUnique": true
+ },
+ "webhook_events_source_created_idx": {
+ "name": "webhook_events_source_created_idx",
+ "columns": [
+ "source",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "webhook_events_status_idx": {
+ "name": "webhook_events_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apikey": {
+ "name": "apikey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "start": {
+ "name": "start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refill_interval": {
+ "name": "refill_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refill_amount": {
+ "name": "refill_amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_refill_at": {
+ "name": "last_refill_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "rate_limit_enabled": {
+ "name": "rate_limit_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "rate_limit_time_window": {
+ "name": "rate_limit_time_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rate_limit_max": {
+ "name": "rate_limit_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apikey_config_id_idx": {
+ "name": "apikey_config_id_idx",
+ "columns": [
+ "config_id"
+ ],
+ "isUnique": false
+ },
+ "apikey_reference_id_idx": {
+ "name": "apikey_reference_id_idx",
+ "columns": [
+ "reference_id"
+ ],
+ "isUnique": false
+ },
+ "apikey_key_idx": {
+ "name": "apikey_key_idx",
+ "columns": [
+ "key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "member": {
+ "name": "member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization": {
+ "name": "organization",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_username": {
+ "name": "display_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ },
+ "user_username_unique": {
+ "name": "user_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 908f4618..0f58d87d 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -218,6 +218,13 @@
"when": 1778475474332,
"tag": "0031_talented_skullbuster",
"breakpoints": true
+ },
+ {
+ "idx": 32,
+ "version": "6",
+ "when": 1778557854518,
+ "tag": "0032_webdav-class2-state",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/db/schema.ts b/server/db/schema.ts
index ca332dbf..4ba6541d 100644
--- a/server/db/schema.ts
+++ b/server/db/schema.ts
@@ -19,6 +19,42 @@ export const matters = sqliteTable('matters', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
+export const webdavDeadProperties = sqliteTable(
+ 'webdav_dead_properties',
+ {
+ id: text('id').primaryKey(),
+ orgId: text('org_id').notNull(),
+ resourcePath: text('resource_path').notNull(),
+ namespace: text('namespace').notNull(),
+ name: text('name').notNull(),
+ value: text('value').notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
+ },
+ (t) => [
+ uniqueIndex('webdav_dead_properties_resource_prop_uniq').on(t.orgId, t.resourcePath, t.namespace, t.name),
+ index('webdav_dead_properties_resource_idx').on(t.orgId, t.resourcePath),
+ ],
+)
+
+export const webdavLocks = sqliteTable(
+ 'webdav_locks',
+ {
+ id: text('id').primaryKey(),
+ token: text('token').notNull().unique(),
+ orgId: text('org_id').notNull(),
+ resourcePath: text('resource_path').notNull(),
+ owner: text('owner').notNull().default(''),
+ depth: text('depth').notNull().default('infinity'),
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
+ },
+ (t) => [
+ index('webdav_locks_resource_idx').on(t.orgId, t.resourcePath),
+ index('webdav_locks_expires_idx').on(t.expiresAt),
+ ],
+)
+
export const storages = sqliteTable('storages', {
id: text('id').primaryKey(),
title: text('title').notNull(),
diff --git a/server/routes/webdav.integration.test.ts b/server/routes/webdav.integration.test.ts
index 4404f302..11b674ee 100644
--- a/server/routes/webdav.integration.test.ts
+++ b/server/routes/webdav.integration.test.ts
@@ -214,6 +214,176 @@ describe('WebDAV API', () => {
expect(hiddenRes.status).toBe(404)
})
+ it('PROPFIND supports prop, propname, allprop include, explicit depths, and rejects infinity', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read'] })
+ await folder(db, workspace.id, { id: 'docs', name: 'Docs' })
+ await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' })
+
+ const prop = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: `
+
+
+ `,
+ })
+ expect(prop.status).toBe(207)
+ const propXml = await prop.text()
+ expect(propXml).toContain('Docs')
+ expect(propXml).toContain('HTTP/1.1 404 Not Found')
+ expect(propXml).not.toContain('readme.txt')
+
+ const propname = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(propname.status).toBe(207)
+ const propnameXml = await propname.text()
+ expect(propnameXml).toContain('')
+ expect(propnameXml).toContain(`/dav/${workspace.slug}/Docs/readme.txt`)
+
+ const defaultNamespace = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: `
+
+
+
+ `,
+ })
+ expect(defaultNamespace.status).toBe(207)
+ expect(await defaultNamespace.text()).toContain('Docs')
+
+ const allprop = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(allprop.status).toBe(207)
+ expect(await allprop.text()).toContain('HTTP/1.1 404 Not Found')
+
+ const invalidRequestType = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(invalidRequestType.status).toBe(400)
+
+ const infinity = await app.request(`/dav/${workspace.slug}/Docs`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: 'infinity' }),
+ })
+ expect(infinity.status).toBe(403)
+ expect(await infinity.text()).toContain('propfind-finite-depth')
+ })
+
+ it('PROPPATCH stores and removes dead properties visible to later PROPFIND', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await file(db, workspace.id, { id: 'dead-props', name: 'dead-props.txt' })
+
+ const set = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: `
+ blue
+ `,
+ })
+ expect(set.status).toBe(207)
+ expect(await set.text()).toContain('HTTP/1.1 200 OK')
+
+ const find = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(find.status).toBe(207)
+ expect(await find.text()).toContain('blue')
+
+ const defaultDeadPropertyNamespace = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: `
+ matte
+ red
+ striped
+ `,
+ })
+ expect(defaultDeadPropertyNamespace.status).toBe(207)
+
+ const defaultDeadPropertyFind = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ const defaultDeadPropertyXml = await defaultDeadPropertyFind.text()
+ expect(defaultDeadPropertyXml).toContain('matte')
+ expect(defaultDeadPropertyXml).toContain('red')
+ expect(defaultDeadPropertyXml).toContain('striped')
+
+ const invalid = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'bad',
+ })
+ expect(invalid.status).toBe(403)
+
+ const badInstruction = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(badInstruction.status).toBe(403)
+
+ const missingProp = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(missingProp.status).toBe(403)
+
+ const atomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: `
+ circle
+ bad
+ `,
+ })
+ expect(atomicFailure.status).toBe(403)
+
+ const afterAtomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(await afterAtomicFailure.text()).toContain('HTTP/1.1 404 Not Found')
+
+ const remove = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(remove.status).toBe(207)
+
+ const removed = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(await removed.text()).toContain('HTTP/1.1 404 Not Found')
+ })
+
it('GET returns file bytes directly and HEAD returns coherent file headers', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
@@ -356,8 +526,9 @@ describe('WebDAV API', () => {
const res = await app.request('/dav/', { method: 'OPTIONS', headers: basicHeaders(account.email, key) })
expect(res.status).toBe(204)
- expect(res.headers.get('DAV')).toBe('1')
+ expect(res.headers.get('DAV')).toBe('1, 2')
expect(res.headers.get('Allow')).toContain('PROPFIND')
+ expect(res.headers.get('Allow')).toContain('LOCK')
})
it('rejects API keys when verification throws', async () => {
@@ -496,6 +667,13 @@ describe('WebDAV API', () => {
headers: basicHeaders(account.email, key),
})
expect(fileParent.status).toBe(405)
+
+ const unsupportedBody = await app.request(`/dav/${workspace.slug}/BodyCollection`, {
+ method: 'MKCOL',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(unsupportedBody.status).toBe(415)
})
it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging', async () => {
@@ -590,6 +768,703 @@ describe('WebDAV API', () => {
}),
})
expect(existing.status).toBe(412)
+
+ const root = await app.request(`/dav/${workspace.slug}/source.txt`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/` }),
+ })
+ expect(root.status).toBe(405)
+ })
+
+ it('COPY recursively copies collections and rejects copying into own descendant', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['write'] })
+ await folder(db, workspace.id, { id: 'src-folder', name: 'Source' })
+ await folder(db, workspace.id, { id: 'nested-folder', name: 'Nested', parent: 'Source' })
+ await file(db, workspace.id, { id: 'nested-file', name: 'note.txt', parent: 'Source/Nested', size: 12 })
+
+ const copied = await app.request(`/dav/${workspace.slug}/Source`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/Copied`,
+ Depth: 'infinity',
+ }),
+ })
+ expect(copied.status).toBe(201)
+ const rows = await db.all<{ name: string; parent: string }>(
+ sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND parent LIKE 'Copied%' ORDER BY parent, name`,
+ )
+ expect(rows).toContainEqual({ name: 'Nested', parent: 'Copied' })
+ expect(rows).toContainEqual({ name: 'note.txt', parent: 'Copied/Nested' })
+
+ const descendant = await app.request(`/dav/${workspace.slug}/Source`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/Source/Child`,
+ }),
+ })
+ expect(descendant.status).toBe(403)
+
+ const badDepth = await app.request(`/dav/${workspace.slug}/Source`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/BadDepth`,
+ Depth: '1',
+ }),
+ })
+ expect(badDepth.status).toBe(400)
+
+ await folder(db, workspace.id, { id: 'existing-copy-root', name: 'ExistingCopy' })
+ const replacedCollection = await app.request(`/dav/${workspace.slug}/Source`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/ExistingCopy`,
+ Depth: '0',
+ }),
+ })
+ expect(replacedCollection.status).toBe(201)
+ })
+
+ it('COPY enforces destination locks and rolls back collection copy quota on storage failure', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['write'] })
+ await folder(db, workspace.id, { id: 'locked-target', name: 'LockedTarget' })
+ await file(db, workspace.id, { id: 'copy-locked-source', name: 'locked-source.txt', size: 12 })
+
+ const locked = await app.request(`/dav/${workspace.slug}/LockedTarget`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'tester',
+ })
+ expect(locked.status).toBe(200)
+
+ const blocked = await app.request(`/dav/${workspace.slug}/locked-source.txt`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/LockedTarget/locked-source.txt`,
+ }),
+ })
+ expect(blocked.status).toBe(423)
+
+ await folder(db, workspace.id, { id: 'rollback-source', name: 'RollbackSource' })
+ await file(db, workspace.id, { id: 'rollback-file', name: 'data.bin', parent: 'RollbackSource', size: 12 })
+ vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed'))
+
+ const failed = await app.request(`/dav/${workspace.slug}/RollbackSource`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/RollbackCopy`,
+ Depth: 'infinity',
+ }),
+ })
+ expect(failed.status).toBe(500)
+ const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`)
+ expect(storageRows[0]?.used).toBe(0)
+ const partialRows = await db.all<{ name: string }>(
+ sql`SELECT name FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND (name = 'RollbackCopy' OR parent LIKE 'RollbackCopy%')`,
+ )
+ expect(partialRows).toEqual([])
+ })
+
+ it('MOVE keeps collection descendant paths consistent and rejects descendant moves', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['write'] })
+ await folder(db, workspace.id, { id: 'move-folder', name: 'MoveMe' })
+ await folder(db, workspace.id, { id: 'move-child', name: 'Child', parent: 'MoveMe' })
+ await file(db, workspace.id, { id: 'move-file', name: 'note.txt', parent: 'MoveMe/Child' })
+
+ const moved = await app.request(`/dav/${workspace.slug}/MoveMe`, {
+ method: 'MOVE',
+ headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/Moved` }),
+ })
+ expect(moved.status).toBe(201)
+ const rows = await db.all<{ id: string; parent: string }>(
+ sql`SELECT id, parent FROM matters WHERE id IN ('move-child', 'move-file') ORDER BY id`,
+ )
+ expect(rows).toEqual([
+ { id: 'move-child', parent: 'Moved' },
+ { id: 'move-file', parent: 'Moved/Child' },
+ ])
+
+ const descendant = await app.request(`/dav/${workspace.slug}/Moved`, {
+ method: 'MOVE',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/Moved/Child/Sub`,
+ }),
+ })
+ expect(descendant.status).toBe(403)
+ })
+
+ it('write methods enforce WebDAV If and lock preconditions before mutations', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['write'] })
+ await file(db, workspace.id, { id: 'guarded-file', name: 'guarded.txt' })
+ await file(db, workspace.id, { id: 'move-guarded-file', name: 'move-guarded.txt' })
+ await file(db, workspace.id, { id: 'copy-guarded-file', name: 'copy-guarded.txt' })
+ await file(db, workspace.id, { id: 'delete-guarded-file', name: 'delete-guarded.txt' })
+
+ const proppatchIfFailed = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'application/xml' }),
+ body: 'blue',
+ })
+ expect(proppatchIfFailed.status).toBe(412)
+
+ const mkcolIfFailed = await app.request(`/dav/${workspace.slug}/BlockedByIf`, {
+ method: 'MKCOL',
+ headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
+ })
+ expect(mkcolIfFailed.status).toBe(412)
+
+ const deleteIfFailed = await app.request(`/dav/${workspace.slug}/delete-guarded.txt`, {
+ method: 'DELETE',
+ headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
+ })
+ expect(deleteIfFailed.status).toBe(412)
+
+ const moveIfFailed = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
+ method: 'MOVE',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/moved-guarded.txt`,
+ If: '(["stale"])',
+ }),
+ })
+ expect(moveIfFailed.status).toBe(412)
+
+ const moveToSelf = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
+ method: 'MOVE',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/move-guarded.txt`,
+ }),
+ })
+ expect(moveToSelf.status).toBe(204)
+
+ const copyIfFailed = await app.request(`/dav/${workspace.slug}/copy-guarded.txt`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/copied-guarded.txt`,
+ If: '(["stale"])',
+ }),
+ })
+ expect(copyIfFailed.status).toBe(412)
+
+ const lock = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(lock.status).toBe(200)
+
+ const deleteLocked = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
+ method: 'DELETE',
+ headers: basicHeaders(account.email, key),
+ })
+ expect(deleteLocked.status).toBe(423)
+ })
+
+ it('DELETE on collections removes descendants from WebDAV listings', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await folder(db, workspace.id, { id: 'delete-folder', name: 'DeleteMe' })
+ await file(db, workspace.id, { id: 'delete-file', name: 'gone.txt', parent: 'DeleteMe' })
+
+ const del = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
+ method: 'DELETE',
+ headers: basicHeaders(account.email, key),
+ })
+ expect(del.status).toBe(204)
+
+ const listing = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key),
+ })
+ expect(listing.status).toBe(404)
+ const rows = await db.all<{ status: string }>(
+ sql`SELECT status FROM matters WHERE id IN ('delete-folder', 'delete-file') ORDER BY id`,
+ )
+ expect(rows).toEqual([{ status: 'trashed' }, { status: 'trashed' }])
+ })
+
+ it('moves, copies, and deletes WebDAV dead properties and locks with namespace changes', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await file(db, workspace.id, { id: 'state-file', name: 'state.txt' })
+
+ const patch = await app.request(`/dav/${workspace.slug}/state.txt`, {
+ method: 'PROPPATCH',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'green',
+ })
+ expect(patch.status).toBe(207)
+
+ const lock = await app.request(`/dav/${workspace.slug}/state.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'tester',
+ })
+ expect(lock.status).toBe(200)
+ const token = lock.headers.get('Lock-Token') ?? ''
+
+ const moved = await app.request(`/dav/${workspace.slug}/state.txt`, {
+ method: 'MOVE',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/moved-state.txt`,
+ 'Lock-Token': token,
+ }),
+ })
+ expect(moved.status).toBe(201)
+
+ const movedProps = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ const movedXml = await movedProps.text()
+ expect(movedXml).toContain('green')
+ expect(movedXml).toContain(token.slice(1, -1))
+
+ const copied = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
+ method: 'COPY',
+ headers: basicHeaders(account.email, key, {
+ Destination: `http://localhost/dav/${workspace.slug}/copied-state.txt`,
+ 'Lock-Token': token,
+ }),
+ })
+ expect(copied.status).toBe(201)
+
+ const copiedProps = await app.request(`/dav/${workspace.slug}/copied-state.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ const copiedXml = await copiedProps.text()
+ expect(copiedXml).toContain('green')
+ expect(copiedXml).not.toContain(token.slice(1, -1))
+
+ const del = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
+ method: 'DELETE',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
+ })
+ expect(del.status).toBe(204)
+
+ const recreate = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
+ body: 'new',
+ })
+ expect(recreate.status).toBe(201)
+
+ const stale = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ const staleXml = await stale.text()
+ expect(staleXml).toContain('HTTP/1.1 404 Not Found')
+ expect(staleXml).not.toContain(token.slice(1, -1))
+ })
+
+ it('If header evaluates ETag matches, misses, and Not conditions', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await file(db, workspace.id, { id: 'if-file', name: 'if.txt', size: 12 })
+
+ const head = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'HEAD',
+ headers: basicHeaders(account.email, key),
+ })
+ const firstEtag = head.headers.get('ETag') ?? ''
+
+ const taggedMatch = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, {
+ If: ` ([${firstEtag}])`,
+ 'Content-Type': 'text/plain',
+ }),
+ body: 'tagged',
+ })
+ expect(taggedMatch.status).toBe(204)
+
+ const updatedHead = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'HEAD',
+ headers: basicHeaders(account.email, key),
+ })
+ const etag = updatedHead.headers.get('ETag') ?? ''
+
+ const matched = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { If: `([${etag}])`, 'Content-Type': 'text/plain' }),
+ body: 'matched',
+ })
+ expect(matched.status).toBe(204)
+
+ const missed = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'text/plain' }),
+ body: 'missed',
+ })
+ expect(missed.status).toBe(412)
+
+ const randomLockToken = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, {
+ If: '()',
+ 'Content-Type': 'text/plain',
+ }),
+ body: 'missed',
+ })
+ expect(randomLockToken.status).toBe(412)
+
+ const taggedExternalResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, {
+ If: ' ()',
+ 'Content-Type': 'text/plain',
+ }),
+ body: 'missed',
+ })
+ expect(taggedExternalResource.status).toBe(412)
+
+ const tokenTaggedResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, {
+ If: ' (["stale"])',
+ 'Content-Type': 'text/plain',
+ }),
+ body: 'missed',
+ })
+ expect(tokenTaggedResource.status).toBe(412)
+
+ const emptyStateList = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { If: '()', 'Content-Type': 'text/plain' }),
+ body: 'missed',
+ })
+ expect(emptyStateList.status).toBe(412)
+
+ const malformedTaggedUrl = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, {
+ If: ' (["stale"])',
+ 'Content-Type': 'text/plain',
+ }),
+ body: 'missed',
+ })
+ expect(malformedTaggedUrl.status).toBe(412)
+
+ const invalidSyntax = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { If: 'Not a state list', 'Content-Type': 'text/plain' }),
+ body: 'missed',
+ })
+ expect(invalidSyntax.status).toBe(412)
+
+ const notMatched = await app.request(`/dav/${workspace.slug}/if.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { If: '(Not ["stale"])', 'Content-Type': 'text/plain' }),
+ body: 'not matched',
+ })
+ expect(notMatched.status).toBe(204)
+ })
+
+ it('LOCK and UNLOCK expose Class 2 state and enforce write tokens', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await file(db, workspace.id, { id: 'lock-file', name: 'locked.txt', size: 12 })
+ await file(db, workspace.id, { id: 'other-lock-file', name: 'other-locked.txt', size: 12 })
+
+ const locked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, {
+ Depth: '0',
+ Timeout: 'Second-600',
+ 'Content-Type': 'application/xml',
+ }),
+ body: 'tester',
+ })
+ expect(locked.status).toBe(200)
+ const token = locked.headers.get('Lock-Token') ?? ''
+ expect(token).toMatch(/^',
+ })
+ expect(refreshWithBody.status).toBe(400)
+
+ const refreshWithMultipleTokens = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, {
+ If: `(${token})()`,
+ Timeout: 'Second-1200',
+ }),
+ })
+ expect(refreshWithMultipleTokens.status).toBe(400)
+
+ const conflictingLock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(conflictingLock.status).toBe(423)
+
+ const wrongResourceRefresh = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
+ })
+ expect(wrongResourceRefresh.status).toBe(412)
+
+ const badRefresh = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { If: '()' }),
+ })
+ expect(badRefresh.status).toBe(412)
+
+ const shared = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(shared.status).toBe(422)
+
+ const malformedLock = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(malformedLock.status).toBe(422)
+
+ const unsupportedDepth = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(unsupportedDepth.status).toBe(400)
+
+ const missingLockTarget = await app.request(`/dav/${workspace.slug}/missing-lock-target.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(missingLockTarget.status).toBe(201)
+ const createdToken = missingLockTarget.headers.get('Lock-Token') ?? ''
+ expect(createdToken).toMatch(/^',
+ })
+ expect(missingLockParent.status).toBe(409)
+
+ const missingUnlockToken = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key),
+ })
+ expect(missingUnlockToken.status).toBe(400)
+
+ const missingUnlockTarget = await app.request(`/dav/${workspace.slug}/absent-unlock-target.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
+ })
+ expect(missingUnlockTarget.status).toBe(404)
+
+ const invalidUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': '' }),
+ })
+ expect(invalidUnlock.status).toBe(409)
+
+ const unlocked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
+ })
+ expect(unlocked.status).toBe(204)
+
+ const afterUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
+ body: 'after',
+ })
+ expect(afterUnlock.status).toBe(204)
+ })
+
+ it('LOCK refresh accepts descendant URLs inside a depth-infinity lock scope only', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const secondWorkspace = await teamWorkspace(db, {
+ id: 'refresh-other-workspace',
+ slug: 'refresh-other-workspace',
+ userId: account.id,
+ name: 'Refresh Other Workspace',
+ })
+ const key = await apiKey(auth, account.id, { webdav: ['write'] })
+ await folder(db, workspace.id, { id: 'refresh-folder', name: 'RefreshScope' })
+ await file(db, workspace.id, { id: 'refresh-child', name: 'child.txt', parent: 'RefreshScope' })
+ await file(db, workspace.id, { id: 'refresh-outside', name: 'outside.txt' })
+ await folder(db, secondWorkspace.id, { id: 'refresh-other-folder', name: 'RefreshScope' })
+ await file(db, secondWorkspace.id, {
+ id: 'refresh-other-child',
+ name: 'child.txt',
+ parent: 'RefreshScope',
+ })
+
+ const locked = await app.request(`/dav/${workspace.slug}/RefreshScope`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'tester',
+ })
+ expect(locked.status).toBe(200)
+ const token = locked.headers.get('Lock-Token') ?? ''
+
+ const descendantRefresh = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
+ })
+ expect(descendantRefresh.status).toBe(200)
+ expect(descendantRefresh.headers.get('Lock-Token')).toBeNull()
+ expect(await descendantRefresh.text()).toContain(token.slice(1, -1))
+
+ const outsideRefresh = await app.request(`/dav/${workspace.slug}/outside.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
+ })
+ expect(outsideRefresh.status).toBe(412)
+
+ const otherWorkspaceRefresh = await app.request(`/dav/${secondWorkspace.slug}/RefreshScope/child.txt`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
+ })
+ expect(otherWorkspaceRefresh.status).toBe(412)
+
+ const outsideUnlock = await app.request(`/dav/${workspace.slug}/outside.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
+ })
+ expect(outsideUnlock.status).toBe(409)
+
+ const descendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
+ method: 'UNLOCK',
+ headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
+ })
+ expect(descendantUnlock.status).toBe(204)
+
+ const afterDescendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
+ method: 'PUT',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
+ body: 'after unlock',
+ })
+ expect(afterDescendantUnlock.status).toBe(204)
+ })
+
+ it('PROPFIND lockdiscovery includes inherited depth-infinity locks', async () => {
+ const { app, db, auth } = await createTestApp()
+ await authedHeaders(app)
+ await seedStorage(db)
+ const workspace = await org(db)
+ const account = await userAccount(db)
+ const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
+ await folder(db, workspace.id, { id: 'discovery-folder', name: 'DiscoveryScope' })
+ await file(db, workspace.id, { id: 'discovery-child', name: 'child.txt', parent: 'DiscoveryScope' })
+
+ const locked = await app.request(`/dav/${workspace.slug}/DiscoveryScope`, {
+ method: 'LOCK',
+ headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
+ body: 'tester',
+ })
+ expect(locked.status).toBe(200)
+ const token = locked.headers.get('Lock-Token') ?? ''
+
+ const childProps = await app.request(`/dav/${workspace.slug}/DiscoveryScope/child.txt`, {
+ method: 'PROPFIND',
+ headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
+ body: '',
+ })
+ expect(childProps.status).toBe(207)
+ const xml = await childProps.text()
+ expect(xml).toContain(token.slice(1, -1))
+ expect(xml).toContain('infinity')
})
it('returns WebDAV path errors for missing GET and DELETE targets', async () => {
@@ -647,7 +1522,7 @@ describe('WebDAV API', () => {
])
})
- it('COPY honors Overwrite header for existing destinations and rejects collection copy explicitly', async () => {
+ it('COPY honors Overwrite header for existing destinations and copies collection roots', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
@@ -681,10 +1556,14 @@ describe('WebDAV API', () => {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied%20Folder`,
+ Depth: '0',
}),
})
- expect(collection.status).toBe(403)
- expect(await collection.text()).toContain('Collection COPY is not supported')
+ expect(collection.status).toBe(201)
+ const folders = await db.all<{ name: string; parent: string }>(
+ sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND name = 'Copied Folder'`,
+ )
+ expect(folders[0]).toEqual({ name: 'Copied Folder', parent: '' })
})
it('COPY rolls back quota reservation when storage copy fails', async () => {
diff --git a/server/routes/webdav.ts b/server/routes/webdav.ts
index b4faff40..41cc475f 100644
--- a/server/routes/webdav.ts
+++ b/server/routes/webdav.ts
@@ -1,4 +1,4 @@
-import { and, eq } from 'drizzle-orm'
+import { and, eq, like, or } from 'drizzle-orm'
import type { Context } from 'hono'
import { Hono } from 'hono'
import { DirType, ObjectStatus } from '../../shared/constants'
@@ -28,11 +28,37 @@ import {
WebDavPathError,
type WebDavTarget,
} from '../services/webdav-path'
-import { davEtag, matterEntry, mountRootEntry, multistatus, workspaceEntry } from '../services/webdav-xml'
+import {
+ activeLocks,
+ applyDeadPropertyUpdate,
+ conflictingLocks,
+ copyDeadProperties,
+ createLock,
+ deleteWebDavState,
+ listDeadProperties,
+ moveWebDavState,
+ refreshLock,
+ removeLock,
+} from '../services/webdav-state'
+import {
+ type DavEntry,
+ davEtag,
+ errorXml,
+ lockDiscoveryXml,
+ matterEntry,
+ mountRootEntry,
+ multistatus,
+ parseLockInfoXml,
+ parsePropfindXml,
+ parseProppatchXml,
+ proppatchMultistatus,
+ workspaceEntry,
+ xmlResponse,
+} from '../services/webdav-xml'
const s3 = new S3Service()
const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD'])
-const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY'])
+const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK'])
const WEBDAV_RESOURCE = 'webdav'
const WEBDAV_CONFIG_ID = 'webdav'
const WEBDAV_REALM = 'Basic realm="ZPan WebDAV"'
@@ -226,8 +252,157 @@ function bytesBody(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
+function resourcePath(target: WebDavTarget): string {
+ if (!target.workspace) return ''
+ return target.matter
+ ? joinMatterPath(target.matter.parent, target.matter.name)
+ : joinMatterPath(target.parent, target.name)
+}
+
+function targetHref(target: WebDavTarget): string {
+ if (target.mountRoot) return '/dav/'
+ const workspace = requireWorkspace(target)
+ if (!target.matter) return `/dav/${encodeURIComponent(workspace.slug)}/`
+ const path = joinMatterPath(target.matter.parent, target.matter.name)
+ const href = `/dav/${encodeURIComponent(workspace.slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
+ return target.matter.dirtype === DirType.FILE ? href : `${href}/`
+}
+
+function parseTimeout(header: string | undefined): number {
+ if (!header) return 3600
+ const second = header
+ .split(',')
+ .map((value) => value.trim())
+ .find((value) => /^Second-\d+$/i.test(value))
+ if (!second) return 3600
+ return Math.min(Number(second.slice('Second-'.length)), 604800)
+}
+
+function lockTokenHeader(c: DavContext): string | null {
+ const header = c.req.header('Lock-Token')
+ return header?.replace(/^<|>$/g, '') ?? null
+}
+
+function submittedLockTokens(c: DavContext): Set {
+ const tokens = new Set()
+ const direct = lockTokenHeader(c)
+ if (direct) tokens.add(direct)
+ const ifHeader = c.req.header('If')
+ if (!ifHeader) return tokens
+ for (const match of ifHeader.matchAll(/<([^>]+)>/g)) {
+ if (match[1].startsWith('opaquelocktoken:')) tokens.add(match[1])
+ }
+ return tokens
+}
+
+function lockRefreshToken(c: DavContext): string | Response | null {
+ const ifHeader = c.req.header('If')
+ if (!ifHeader) return null
+ const tokens = [...ifHeader.matchAll(/<([^>]+)>/g)]
+ .map((match) => match[1])
+ .filter((token) => token.startsWith('opaquelocktoken:'))
+ if (tokens.length === 0) return null
+ if (tokens.length !== 1) return xmlResponse(errorXml('lock-token-submitted'), 400)
+ return tokens[0]
+}
+
+async function lockPrecondition(c: DavContext, target: WebDavTarget): Promise {
+ const workspace = requireWorkspace(target)
+ const locks = await activeLocks(c.get('platform').db, workspace.id, resourcePath(target))
+ if (locks.length === 0) return null
+ const tokens = submittedLockTokens(c)
+ if (locks.every((lock) => tokens.has(lock.token))) return null
+ return xmlResponse(errorXml('lock-token-submitted', 'A matching lock token is required.'), 423)
+}
+
+async function ifHeaderPrecondition(c: DavContext, auth: DavAuth, target: WebDavTarget): Promise {
+ const header = c.req.header('If')
+ if (!header) return null
+ if (await evaluateIfHeader(c, auth, header, target)) return null
+ return xmlResponse(errorXml('condition-failed', 'If header conditions did not match.'), 412)
+}
+
+async function evaluateIfHeader(
+ c: DavContext,
+ auth: DavAuth,
+ header: string,
+ fallback: WebDavTarget,
+): Promise {
+ const clauses = [...header.matchAll(/(?:<([^>]+)>\s*)?(\([^)]*\))/g)]
+ if (clauses.length === 0) return false
+ for (const clause of clauses) {
+ const target = clause[1] ? await ifTaggedTarget(c, auth, clause[1]) : fallback
+ if (!target) continue
+ const workspace = target.workspace
+ const etag = target.matter ? matterEtag(target.matter) : null
+ const locks = workspace ? await activeLocks(c.get('platform').db, workspace.id, resourcePath(target)) : []
+ const lockTokens = new Set(locks.map((lock) => lock.token))
+ const list = clause[2]
+ const conditions = [...list.matchAll(/(Not\s+)?(?:\[([^\]]+)\]|<([^>]+)>)/gi)]
+ if (conditions.length === 0) continue
+ if (
+ conditions.every((condition) => {
+ const negated = Boolean(condition[1])
+ const value = condition[2] ?? condition[3]
+ const matched = value.startsWith('opaquelocktoken:') ? lockTokens.has(value) : etag === value
+ return negated ? !matched : matched
+ })
+ ) {
+ return true
+ }
+ }
+ return false
+}
+
+async function ifTaggedTarget(c: DavContext, auth: DavAuth, tag: string): Promise {
+ if (tag.startsWith('opaquelocktoken:')) return null
+ try {
+ const url = new URL(tag, c.req.url)
+ if (url.origin !== new URL(c.req.url).origin) return null
+ return await resolveWebDavPath(c.get('platform').db, auth.userId, url.pathname)
+ } catch {
+ return null
+ }
+}
+
+async function davEntry(c: DavContext, target: WebDavTarget): Promise {
+ const db = c.get('platform').db
+ if (target.mountRoot) return mountRootEntry()
+ const workspace = requireWorkspace(target)
+ const path = resourcePath(target)
+ const [deadProperties, locks] = await Promise.all([
+ listDeadProperties(db, workspace.id, path),
+ activeLocks(db, workspace.id, path),
+ ])
+ return target.matter
+ ? matterEntry(workspace, target.matter, deadProperties, locks)
+ : workspaceEntry(workspace, deadProperties, locks)
+}
+
+async function listDescendants(db: Env['Variables']['platform']['db'], orgId: string, rootPath: string) {
+ return db
+ .select()
+ .from(matters)
+ .where(
+ and(eq(matters.orgId, orgId), eq(matters.status, ObjectStatus.ACTIVE), like(matters.parent, `${rootPath}/%`)),
+ )
+}
+
+async function restoreActiveMatterRows(
+ db: Env['Variables']['platform']['db'],
+ rows: NonNullable[],
+): Promise {
+ const now = new Date()
+ for (const row of rows) {
+ await db
+ .update(matters)
+ .set({ status: ObjectStatus.ACTIVE, trashedAt: null, updatedAt: now })
+ .where(and(eq(matters.id, row.id), eq(matters.orgId, row.orgId)))
+ }
+}
+
const app = new Hono().on(
- ['OPTIONS', 'PROPFIND', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY'],
+ ['OPTIONS', 'PROPFIND', 'PROPPATCH', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'LOCK', 'UNLOCK'],
'/*',
async (c) => {
const auth = await requireWebDavApiKey(c)
@@ -237,10 +412,15 @@ const app = new Hono().on(
case 'OPTIONS':
return new Response(null, {
status: 204,
- headers: { Allow: 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY', DAV: '1' },
+ headers: {
+ Allow: 'OPTIONS, PROPFIND, PROPPATCH, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, LOCK, UNLOCK',
+ DAV: '1, 2',
+ },
})
case 'PROPFIND':
return propfind(c, auth)
+ case 'PROPPATCH':
+ return proppatch(c, auth)
case 'GET':
case 'HEAD':
return readFile(c, auth)
@@ -254,6 +434,10 @@ const app = new Hono().on(
return moveMatter(c, auth)
case 'COPY':
return copyMatterRoute(c, auth)
+ case 'LOCK':
+ return lockMatter(c, auth)
+ case 'UNLOCK':
+ return unlockMatter(c, auth)
default:
return c.text('Method Not Allowed', 405)
}
@@ -265,27 +449,77 @@ async function propfind(c: DavContext, auth: DavAuth): Promise {
try {
const target = await resolveWebDavPath(db, auth.userId, davPath(c))
const depth = c.req.header('Depth') ?? '1'
- const entries = []
+ if (depth !== '0' && depth !== '1') {
+ return xmlResponse(errorXml('propfind-finite-depth', 'Depth infinity is not supported for PROPFIND.'), 403)
+ }
+ const request = parsePropfindXml(await c.req.text())
+ const entries: DavEntry[] = []
if (target.mountRoot) {
entries.push(mountRootEntry())
- if (depth !== '0') entries.push(...(await listUserWorkspaces(db, auth.userId)).map(workspaceEntry))
+ if (depth !== '0') {
+ for (const workspace of await listUserWorkspaces(db, auth.userId)) {
+ const workspaceTarget = { workspace, mountRoot: false, parent: '', name: '', matter: null }
+ entries.push(await davEntry(c, workspaceTarget))
+ }
+ }
} else if (!target.matter) {
+ if (target.name) throw new WebDavPathError('Not found', 404)
const workspace = requireWorkspace(target)
- entries.push(workspaceEntry(workspace))
- if (depth !== '0')
- entries.push(...(await listChildren(db, workspace.id, '')).map((m) => matterEntry(workspace, m)))
+ entries.push(await davEntry(c, target))
+ if (depth !== '0') {
+ for (const matter of await listChildren(db, workspace.id, '')) {
+ entries.push(
+ await davEntry(c, { workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }),
+ )
+ }
+ }
} else {
const workspace = requireWorkspace(target)
- entries.push(matterEntry(workspace, target.matter))
+ entries.push(await davEntry(c, target))
if (depth !== '0' && target.matter.dirtype !== DirType.FILE) {
const parent = joinMatterPath(target.matter.parent, target.matter.name)
- entries.push(...(await listChildren(db, workspace.id, parent)).map((m) => matterEntry(workspace, m)))
+ for (const matter of await listChildren(db, workspace.id, parent)) {
+ entries.push(
+ await davEntry(c, { workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }),
+ )
+ }
}
}
- return c.body(multistatus(entries), 207, { 'Content-Type': 'application/xml; charset=utf-8' })
+ return xmlResponse(multistatus(entries, request), 207)
} catch (e) {
+ if (e instanceof Error && (e.message.includes('XML') || e.message.includes('PROPFIND'))) {
+ return xmlResponse(errorXml('valid-xml', e.message), 400)
+ }
+ return davError(c, e)
+ }
+}
+
+async function proppatch(c: DavContext, auth: DavAuth): Promise {
+ const db = c.get('platform').db
+ try {
+ const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
+ const workspace = requireWorkspace(target)
+ const locked = await lockPrecondition(c, target)
+ if (locked) return locked
+ const ifFailed = await ifHeaderPrecondition(c, auth, target)
+ if (ifFailed) return ifFailed
+ const operations = parseProppatchXml(await c.req.text())
+ await applyDeadPropertyUpdate(db, workspace.id, resourcePath(target), operations)
+ await db
+ .update(matters)
+ .set({ updatedAt: new Date() })
+ .where(and(eq(matters.id, target.matter!.id), eq(matters.orgId, workspace.id)))
+ const properties = operations.map((operation) => operation.property)
+ return xmlResponse(proppatchMultistatus(targetHref(target), properties), 207)
+ } catch (e) {
+ if (
+ e instanceof Error &&
+ (e.message.includes('XML') || e.message.includes('PROPPATCH') || e.message.includes('Protected'))
+ ) {
+ return xmlResponse(errorXml('cannot-modify-protected-property', e.message), 403)
+ }
return davError(c, e)
}
}
@@ -333,6 +567,10 @@ async function putFile(c: DavContext, auth: DavAuth): Promise {
if (!target.name) return c.text('Cannot PUT a collection root', 405)
if (target.matter && target.matter.dirtype !== DirType.FILE)
return c.text('Cannot replace collection with file', 409)
+ const locked = await lockPrecondition(c, target)
+ if (locked) return locked
+ const ifFailed = await ifHeaderPrecondition(c, auth, target)
+ if (ifFailed) return ifFailed
const precondition = target.matter ? preconditionResponse(c, target.matter) : missingPreconditionResponse(c)
if (precondition) return precondition
await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
@@ -398,6 +636,14 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise {
const workspace = requireWorkspace(target)
if (!target.name) return c.text('Cannot create collection root', 405)
if (target.matter) return c.text('Already exists', 405)
+ const body = await c.req.text()
+ if (body.length > 0) {
+ return xmlResponse(errorXml('unsupported-media-type', 'MKCOL request bodies are not supported.'), 415)
+ }
+ const locked = await lockPrecondition(c, target)
+ if (locked) return locked
+ const ifFailed = await ifHeaderPrecondition(c, auth, target)
+ if (ifFailed) return ifFailed
await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
await createMatter(db, {
@@ -425,6 +671,11 @@ async function deleteMatter(c: DavContext, auth: DavAuth): Promise {
const workspace = requireWorkspace(target)
const matter = target.matter
if (!matter) throw new WebDavPathError('Not found', 404)
+ const locked = await lockPrecondition(c, target)
+ if (locked) return locked
+ const ifFailed = await ifHeaderPrecondition(c, auth, target)
+ if (ifFailed) return ifFailed
+ await deleteWebDavState(db, workspace.id, resourcePath(target))
await trashMatter(db, workspace.id, matter.id, auth.userId)
return new Response(null, { status: 204 })
} catch (e) {
@@ -438,6 +689,10 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise {
const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const sourceWorkspace = requireWorkspace(source)
if (!source.matter) throw new WebDavPathError('Not found', 404)
+ const locked = await lockPrecondition(c, source)
+ if (locked) return locked
+ const ifFailed = await ifHeaderPrecondition(c, auth, source)
+ if (ifFailed) return ifFailed
const precondition = preconditionResponse(c, source.matter)
if (precondition) return precondition
const destination = destinationPath(c)
@@ -446,12 +701,26 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise {
const targetWorkspace = requireWorkspace(target)
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace MOVE is not supported', 403)
if (!target.name) return c.text('Cannot move to collection root', 405)
+ if (source.matter.dirtype !== DirType.FILE) {
+ const oldPath = joinMatterPath(source.matter.parent, source.matter.name)
+ const newPath = joinMatterPath(target.parent, target.name)
+ if (newPath === oldPath || newPath.startsWith(`${oldPath}/`)) {
+ return xmlResponse(errorXml('forbidden', 'Cannot move a collection into itself or its descendant.'), 403)
+ }
+ }
+ const targetLocked = await lockPrecondition(c, target)
+ if (targetLocked) return targetLocked
if (target.matter) {
if (target.matter.id === source.matter.id) return new Response(null, { status: 204 })
if (!overwriteAllowed(c)) return c.text('Already exists', 412)
}
await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent)
- if (target.matter) await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
+ const oldPath = resourcePath(source)
+ const newPath = joinMatterPath(target.parent, target.name)
+ if (target.matter) {
+ await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
+ await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
+ }
await updateMatter(
db,
source.matter.id,
@@ -459,6 +728,7 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise {
{ name: target.name, parent: target.parent },
auth.userId,
)
+ await moveWebDavState(db, sourceWorkspace.id, oldPath, newPath)
return new Response(null, { status: 201 })
} catch (e) {
return davError(c, e)
@@ -471,18 +741,30 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise
const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const sourceWorkspace = requireWorkspace(source)
if (!source.matter) throw new WebDavPathError('Not found', 404)
+ const ifFailed = await ifHeaderPrecondition(c, auth, source)
+ if (ifFailed) return ifFailed
const precondition = preconditionResponse(c, source.matter)
if (precondition) return precondition
- if (source.matter.dirtype !== DirType.FILE) return c.text('Collection COPY is not supported', 403)
const destination = destinationPath(c)
if (destination instanceof Response) return destination
const target = await resolveWebDavPath(db, auth.userId, destination)
const targetWorkspace = requireWorkspace(target)
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace COPY is not supported', 403)
if (!target.name) return c.text('Cannot copy to collection root', 405)
+ const oldPath = joinMatterPath(source.matter.parent, source.matter.name)
+ const newPath = joinMatterPath(target.parent, target.name)
+ if (source.matter.dirtype !== DirType.FILE && (newPath === oldPath || newPath.startsWith(`${oldPath}/`))) {
+ return xmlResponse(errorXml('forbidden', 'Cannot copy a collection into itself or its descendant.'), 403)
+ }
+ const targetLocked = await lockPrecondition(c, target)
+ if (targetLocked) return targetLocked
if (target.matter && !overwriteAllowed(c)) return c.text('Already exists', 412)
await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent)
+ if (source.matter.dirtype !== DirType.FILE) {
+ return copyCollection(c, auth, source, target)
+ }
+
let newObject = ''
let reservedUsage: { storageId: string; bytes: number } | null = null
try {
@@ -499,11 +781,15 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise
await s3.copyObject(storage, source.matter.object, storage, newObject)
}
- if (target.matter) await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
+ if (target.matter) {
+ await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
+ await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
+ }
const copy = await copyMatter(db, { ...source.matter, name: target.name }, target.parent, newObject, {
onConflict: 'fail',
userId: auth.userId,
})
+ await copyDeadProperties(db, sourceWorkspace.id, resourcePath(source), joinMatterPath(copy.parent, copy.name))
c.header('Location', matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(copy.parent, copy.name)))
return c.body(null, 201)
} catch (e) {
@@ -522,6 +808,193 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise
}
}
+async function copyCollection(
+ c: DavContext,
+ auth: DavAuth,
+ source: WebDavTarget,
+ target: WebDavTarget,
+): Promise {
+ const db = c.get('platform').db
+ const sourceWorkspace = requireWorkspace(source)
+ const targetWorkspace = requireWorkspace(target)
+ if (!source.matter) throw new WebDavPathError('Not found', 404)
+
+ const depth = c.req.header('Depth') ?? 'infinity'
+ if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
+
+ const sourceRoot = joinMatterPath(source.matter.parent, source.matter.name)
+ const targetRoot = joinMatterPath(target.parent, target.name)
+ const children = await listChildren(db, sourceWorkspace.id, sourceRoot)
+ const descendants = await listDescendants(db, sourceWorkspace.id, sourceRoot)
+ const ordered =
+ depth === 'infinity' ? [...children, ...descendants].sort((a, b) => a.parent.length - b.parent.length) : []
+ const reservedUsage: Array<{ storageId: string; bytes: number }> = []
+ const copiedObjects: Array<{ storage: S3Storage; key: string }> = []
+ const preparedCopies: Array<{ item: (typeof ordered)[number]; targetParent: string; objectKey: string }> = []
+ const createdIds: string[] = []
+ const targetRows =
+ target.matter && target.matter.dirtype !== DirType.FILE
+ ? [
+ target.matter,
+ ...(await listChildren(db, targetWorkspace.id, resourcePath(target))),
+ ...(await listDescendants(db, targetWorkspace.id, resourcePath(target))),
+ ]
+ : target.matter
+ ? [target.matter]
+ : []
+
+ try {
+ for (const item of ordered) {
+ const targetParent =
+ item.parent === sourceRoot ? targetRoot : `${targetRoot}${item.parent.slice(sourceRoot.length)}`
+ let objectKey = ''
+ if (item.dirtype === DirType.FILE && item.object) {
+ const storage = (await getStorage(db, item.storageId)) as unknown as S3Storage | null
+ if (!storage) return c.text('Storage not found', 404)
+ const bytes = item.size ?? 0
+ if (bytes > 0) {
+ const allowed = await incrementUsageIfAllowed(db, targetWorkspace.id, storage.id, bytes)
+ if (!allowed) return c.text('Quota exceeded', 422)
+ reservedUsage.push({ storageId: storage.id, bytes })
+ }
+ objectKey = buildObjectKey({ uid: auth.userId, orgId: targetWorkspace.id, rawExt: fileExt(item.name) })
+ await s3.copyObject(storage, item.object, storage, objectKey)
+ copiedObjects.push({ storage, key: objectKey })
+ }
+ preparedCopies.push({ item, targetParent, objectKey })
+ }
+
+ if (target.matter) {
+ await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
+ await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
+ }
+
+ const rootCopy = await copyMatter(db, { ...source.matter, name: target.name }, target.parent, '', {
+ onConflict: 'fail',
+ userId: auth.userId,
+ })
+ createdIds.push(rootCopy.id)
+ await copyDeadProperties(db, sourceWorkspace.id, sourceRoot, joinMatterPath(rootCopy.parent, rootCopy.name))
+
+ for (const prepared of preparedCopies) {
+ const copy = await copyMatter(db, prepared.item, prepared.targetParent, prepared.objectKey, {
+ onConflict: 'fail',
+ userId: auth.userId,
+ })
+ createdIds.push(copy.id)
+ await copyDeadProperties(
+ db,
+ sourceWorkspace.id,
+ joinMatterPath(prepared.item.parent, prepared.item.name),
+ joinMatterPath(copy.parent, copy.name),
+ )
+ }
+
+ c.header(
+ 'Location',
+ matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(rootCopy.parent, rootCopy.name)),
+ )
+ return c.body(null, 201)
+ } catch (e) {
+ if (createdIds.length > 0) {
+ await db
+ .update(matters)
+ .set({ status: ObjectStatus.TRASHED, trashedAt: Date.now(), updatedAt: new Date() })
+ .where(and(eq(matters.orgId, targetWorkspace.id), or(...createdIds.map((id) => eq(matters.id, id)))))
+ await deleteWebDavState(db, targetWorkspace.id, targetRoot)
+ }
+ if (targetRows.length > 0) await restoreActiveMatterRows(db, targetRows)
+ await Promise.all(copiedObjects.map((object) => s3.deleteObject(object.storage, object.key)))
+ const byStorage = new Map()
+ let total = 0
+ for (const item of reservedUsage) {
+ byStorage.set(item.storageId, (byStorage.get(item.storageId) ?? 0) + item.bytes)
+ total += item.bytes
+ }
+ if (total > 0) await decrementUsage(db, targetWorkspace.id, byStorage, total)
+ throw e
+ }
+}
+
+async function lockMatter(c: DavContext, auth: DavAuth): Promise {
+ const db = c.get('platform').db
+ try {
+ const target = await resolveWebDavPath(db, auth.userId, davPath(c))
+ const workspace = requireWorkspace(target)
+ const body = await c.req.text()
+ const existingToken = lockRefreshToken(c)
+ if (existingToken instanceof Response) return existingToken
+ if (existingToken) {
+ if (body.length > 0) return xmlResponse(errorXml('lock-token-submitted'), 400)
+ const refreshed = await refreshLock(
+ db,
+ workspace.id,
+ resourcePath(target),
+ existingToken,
+ parseTimeout(c.req.header('Timeout')),
+ )
+ if (!refreshed) return xmlResponse(errorXml('lock-token-submitted'), 412)
+ return xmlResponse(lockDiscoveryXml(refreshed), 200)
+ }
+
+ const depth = c.req.header('Depth') ?? 'infinity'
+ if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
+ const path = resourcePath(target)
+ const conflicts = await conflictingLocks(db, workspace.id, path)
+ if (conflicts.length > 0) return xmlResponse(errorXml('no-conflicting-lock'), 423)
+ let lockInfo: { owner: string }
+ try {
+ lockInfo = parseLockInfoXml(body)
+ } catch (e) {
+ return xmlResponse(errorXml('supported-lock', e instanceof Error ? e.message : 'Unsupported lock request.'), 422)
+ }
+ const created = !target.matter && Boolean(target.name)
+ if (created) {
+ await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
+ const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
+ const objectKey = buildObjectKey({ uid: auth.userId, orgId: workspace.id, rawExt: fileExt(target.name) })
+ await s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream')
+ target.matter = await createMatter(db, {
+ orgId: workspace.id,
+ userId: auth.userId,
+ name: target.name,
+ type: 'application/octet-stream',
+ size: 0,
+ dirtype: DirType.FILE,
+ parent: target.parent,
+ object: objectKey,
+ storageId: storage.id,
+ status: ObjectStatus.ACTIVE,
+ })
+ }
+ const lock = await createLock(db, {
+ orgId: workspace.id,
+ resourcePath: path,
+ owner: lockInfo.owner,
+ depth,
+ timeoutSeconds: parseTimeout(c.req.header('Timeout')),
+ })
+ return xmlResponse(lockDiscoveryXml(lock), created ? 201 : 200, { 'Lock-Token': `<${lock.token}>` })
+ } catch (e) {
+ return davError(c, e)
+ }
+}
+
+async function unlockMatter(c: DavContext, auth: DavAuth): Promise {
+ const db = c.get('platform').db
+ try {
+ const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
+ const workspace = requireWorkspace(target)
+ const token = lockTokenHeader(c)
+ if (!token) return xmlResponse(errorXml('lock-token-submitted'), 400)
+ const removed = await removeLock(db, workspace.id, resourcePath(target), token)
+ if (!removed) return xmlResponse(errorXml('lock-token-matches-request-uri'), 409)
+ return new Response(null, { status: 204 })
+ } catch (e) {
+ return davError(c, e)
+ }
+}
+
function matterLocation(requestUrl: string, slug: string, path: string): string {
const url = new URL(requestUrl)
url.pathname = `/dav/${encodeURIComponent(slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
diff --git a/server/services/webdav-state.ts b/server/services/webdav-state.ts
new file mode 100644
index 00000000..09a2107e
--- /dev/null
+++ b/server/services/webdav-state.ts
@@ -0,0 +1,318 @@
+import { and, eq, or, sql } from 'drizzle-orm'
+import { nanoid } from 'nanoid'
+import { webdavDeadProperties, webdavLocks } from '../db/schema'
+import type { Database } from '../platform/interface'
+import { type AtomicQuery, executeWriteTransaction } from './db-transaction'
+
+export interface DavPropertyName {
+ namespace: string
+ name: string
+}
+
+export interface DavDeadProperty extends DavPropertyName {
+ value: string
+}
+
+export interface DavLock {
+ id: string
+ token: string
+ orgId: string
+ resourcePath: string
+ owner: string
+ depth: string
+ expiresAt: Date
+ createdAt: Date
+ updatedAt: Date
+}
+
+export async function listDeadProperties(
+ db: Database,
+ orgId: string,
+ resourcePath: string,
+): Promise {
+ const rows = await db
+ .select({
+ namespace: webdavDeadProperties.namespace,
+ name: webdavDeadProperties.name,
+ value: webdavDeadProperties.value,
+ })
+ .from(webdavDeadProperties)
+ .where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, resourcePath)))
+ return rows
+}
+
+export async function applyDeadPropertyUpdate(
+ db: Database,
+ orgId: string,
+ resourcePath: string,
+ operations: Array<{ action: 'set'; property: DavDeadProperty } | { action: 'remove'; property: DavPropertyName }>,
+): Promise {
+ const now = new Date()
+ const queries: AtomicQuery[] = []
+ for (const operation of operations) {
+ if (operation.action === 'remove') {
+ queries.push(
+ db
+ .delete(webdavDeadProperties)
+ .where(
+ and(
+ eq(webdavDeadProperties.orgId, orgId),
+ eq(webdavDeadProperties.resourcePath, resourcePath),
+ eq(webdavDeadProperties.namespace, operation.property.namespace),
+ eq(webdavDeadProperties.name, operation.property.name),
+ ),
+ ),
+ )
+ continue
+ }
+
+ const property = operation.property
+ queries.push(
+ db
+ .insert(webdavDeadProperties)
+ .values({
+ id: nanoid(),
+ orgId,
+ resourcePath,
+ namespace: property.namespace,
+ name: property.name,
+ value: property.value,
+ updatedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: [
+ webdavDeadProperties.orgId,
+ webdavDeadProperties.resourcePath,
+ webdavDeadProperties.namespace,
+ webdavDeadProperties.name,
+ ],
+ set: { value: property.value, updatedAt: now },
+ }),
+ )
+ }
+ await executeWriteTransaction(db, queries)
+}
+
+export async function deleteWebDavState(db: Database, orgId: string, resourcePath: string): Promise {
+ await executeWriteTransaction(db, [
+ db
+ .delete(webdavDeadProperties)
+ .where(
+ and(
+ eq(webdavDeadProperties.orgId, orgId),
+ or(
+ eq(webdavDeadProperties.resourcePath, resourcePath),
+ sql`${webdavDeadProperties.resourcePath} LIKE ${`${resourcePath}/%`}`,
+ ),
+ ),
+ ),
+ db
+ .delete(webdavLocks)
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ or(eq(webdavLocks.resourcePath, resourcePath), sql`${webdavLocks.resourcePath} LIKE ${`${resourcePath}/%`}`),
+ ),
+ ),
+ ])
+}
+
+export async function moveWebDavState(db: Database, orgId: string, oldPath: string, newPath: string): Promise {
+ const now = new Date()
+ await executeWriteTransaction(db, [
+ db
+ .update(webdavDeadProperties)
+ .set({
+ resourcePath: sql`CASE WHEN ${webdavDeadProperties.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavDeadProperties.resourcePath}, ${oldPath.length + 1}) END`,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(webdavDeadProperties.orgId, orgId),
+ or(
+ eq(webdavDeadProperties.resourcePath, oldPath),
+ sql`${webdavDeadProperties.resourcePath} LIKE ${`${oldPath}/%`}`,
+ ),
+ ),
+ ),
+ db
+ .update(webdavLocks)
+ .set({
+ resourcePath: sql`CASE WHEN ${webdavLocks.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavLocks.resourcePath}, ${oldPath.length + 1}) END`,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ or(eq(webdavLocks.resourcePath, oldPath), sql`${webdavLocks.resourcePath} LIKE ${`${oldPath}/%`}`),
+ ),
+ ),
+ ])
+}
+
+export async function copyDeadProperties(
+ db: Database,
+ orgId: string,
+ sourcePath: string,
+ targetPath: string,
+): Promise {
+ const rows = await db
+ .select()
+ .from(webdavDeadProperties)
+ .where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, sourcePath)))
+ if (rows.length === 0) return
+
+ const now = new Date()
+ await executeWriteTransaction(
+ db,
+ rows.map((row) =>
+ db
+ .insert(webdavDeadProperties)
+ .values({
+ id: nanoid(),
+ orgId,
+ resourcePath: targetPath,
+ namespace: row.namespace,
+ name: row.name,
+ value: row.value,
+ updatedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: [
+ webdavDeadProperties.orgId,
+ webdavDeadProperties.resourcePath,
+ webdavDeadProperties.namespace,
+ webdavDeadProperties.name,
+ ],
+ set: { value: row.value, updatedAt: now },
+ }),
+ ),
+ )
+}
+
+export async function activeLocks(db: Database, orgId: string, resourcePath: string): Promise {
+ await purgeExpiredLocks(db)
+ const now = Date.now()
+ return db
+ .select()
+ .from(webdavLocks)
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ sql`${webdavLocks.expiresAt} > ${now}`,
+ or(
+ eq(webdavLocks.resourcePath, resourcePath),
+ sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
+ sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
+ ),
+ ),
+ )
+}
+
+export async function conflictingLocks(db: Database, orgId: string, resourcePath: string): Promise {
+ await purgeExpiredLocks(db)
+ const now = Date.now()
+ return db
+ .select()
+ .from(webdavLocks)
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ sql`${webdavLocks.expiresAt} > ${now}`,
+ or(
+ eq(webdavLocks.resourcePath, resourcePath),
+ sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
+ sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
+ sql`${webdavLocks.resourcePath} LIKE ${resourcePath} || '/%'`,
+ ),
+ ),
+ )
+}
+
+export async function directLocks(db: Database, orgId: string, resourcePath: string): Promise {
+ await purgeExpiredLocks(db)
+ const now = Date.now()
+ return db
+ .select()
+ .from(webdavLocks)
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ eq(webdavLocks.resourcePath, resourcePath),
+ sql`${webdavLocks.expiresAt} > ${now}`,
+ ),
+ )
+}
+
+export async function createLock(
+ db: Database,
+ input: { orgId: string; resourcePath: string; owner: string; depth: string; timeoutSeconds: number },
+): Promise {
+ const now = new Date()
+ const lock: DavLock = {
+ id: nanoid(),
+ token: `opaquelocktoken:${crypto.randomUUID()}`,
+ orgId: input.orgId,
+ resourcePath: input.resourcePath,
+ owner: input.owner,
+ depth: input.depth,
+ expiresAt: new Date(now.getTime() + input.timeoutSeconds * 1000),
+ createdAt: now,
+ updatedAt: now,
+ }
+ await db.insert(webdavLocks).values(lock)
+ return lock
+}
+
+export async function refreshLock(
+ db: Database,
+ orgId: string,
+ resourcePath: string,
+ token: string,
+ timeoutSeconds: number,
+): Promise {
+ await purgeExpiredLocks(db)
+ const now = new Date()
+ const expiresAt = new Date(now.getTime() + timeoutSeconds * 1000)
+ const rows = await db
+ .update(webdavLocks)
+ .set({ expiresAt, updatedAt: now })
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ eq(webdavLocks.token, token),
+ sql`${webdavLocks.expiresAt} > ${now.getTime()}`,
+ or(
+ eq(webdavLocks.resourcePath, resourcePath),
+ sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
+ sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
+ ),
+ ),
+ )
+ .returning()
+ return rows[0] ?? null
+}
+
+export async function removeLock(db: Database, orgId: string, resourcePath: string, token: string): Promise {
+ await purgeExpiredLocks(db)
+ const rows = await db
+ .delete(webdavLocks)
+ .where(
+ and(
+ eq(webdavLocks.orgId, orgId),
+ eq(webdavLocks.token, token),
+ sql`${webdavLocks.expiresAt} > ${Date.now()}`,
+ or(
+ eq(webdavLocks.resourcePath, resourcePath),
+ sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
+ sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
+ ),
+ ),
+ )
+ .returning({ id: webdavLocks.id })
+ return rows.length > 0
+}
+
+async function purgeExpiredLocks(db: Database): Promise {
+ await db.delete(webdavLocks).where(sql`${webdavLocks.expiresAt} <= ${Date.now()}`)
+}
diff --git a/server/services/webdav-xml.ts b/server/services/webdav-xml.ts
index 795b26ff..dad5636d 100644
--- a/server/services/webdav-xml.ts
+++ b/server/services/webdav-xml.ts
@@ -2,8 +2,11 @@ import { DirType } from '../../shared/constants'
import type { Matter } from './matter'
import type { WebDavWorkspace } from './webdav-path'
import { matterHref, workspaceHref } from './webdav-path'
+import type { DavDeadProperty, DavLock, DavPropertyName } from './webdav-state'
-interface DavEntry {
+export const DAV_NAMESPACE = 'DAV:'
+
+export interface DavEntry {
href: string
displayName: string
collection: boolean
@@ -12,37 +15,66 @@ interface DavEntry {
createdAt: Date
updatedAt: Date
etag: string
+ deadProperties: DavDeadProperty[]
+ locks: DavLock[]
}
-export function workspaceEntry(workspace: WebDavWorkspace): DavEntry {
- const now = new Date()
+export interface PropfindRequest {
+ mode: 'allprop' | 'propname' | 'prop'
+ properties: DavPropertyName[]
+ include: DavPropertyName[]
+}
+
+export type ProppatchOperation =
+ | { action: 'set'; property: DavDeadProperty }
+ | { action: 'remove'; property: DavPropertyName }
+
+export interface LockInfoRequest {
+ owner: string
+}
+
+export function workspaceEntry(
+ workspace: WebDavWorkspace,
+ deadProperties: DavDeadProperty[],
+ locks: DavLock[],
+): DavEntry {
+ const stableDate = new Date(0)
return {
href: workspaceHref(workspace),
displayName: workspace.name,
collection: true,
contentType: 'httpd/unix-directory',
contentLength: 0,
- createdAt: now,
- updatedAt: now,
- etag: davEtag(workspace.id, 0, now),
+ createdAt: stableDate,
+ updatedAt: stableDate,
+ etag: davEtag(workspace.id, 0, stableDate),
+ deadProperties,
+ locks,
}
}
export function mountRootEntry(): DavEntry {
- const now = new Date()
+ const stableDate = new Date(0)
return {
href: '/dav/',
displayName: 'dav',
collection: true,
contentType: 'httpd/unix-directory',
contentLength: 0,
- createdAt: now,
- updatedAt: now,
- etag: davEtag('mount-root', 0, now),
+ createdAt: stableDate,
+ updatedAt: stableDate,
+ etag: davEtag('mount-root', 0, stableDate),
+ deadProperties: [],
+ locks: [],
}
}
-export function matterEntry(workspace: WebDavWorkspace, matter: Matter): DavEntry {
+export function matterEntry(
+ workspace: WebDavWorkspace,
+ matter: Matter,
+ deadProperties: DavDeadProperty[],
+ locks: DavLock[],
+): DavEntry {
const collection = matter.dirtype !== DirType.FILE
return {
href: collection ? `${matterHref(workspace, matter)}/` : matterHref(workspace, matter),
@@ -53,37 +85,329 @@ export function matterEntry(workspace: WebDavWorkspace, matter: Matter): DavEntr
createdAt: matter.createdAt,
updatedAt: matter.updatedAt,
etag: davEtag(matter.id, matter.size ?? 0, matter.updatedAt),
+ deadProperties,
+ locks,
}
}
-export function multistatus(entries: DavEntry[]): string {
- return `\n\n${entries.map(response).join('\n')}\n`
+export function multistatus(entries: DavEntry[], request: PropfindRequest): string {
+ return xmlDocument(
+ `\n${entries.map((entry) => response(entry, request)).join('\n')}\n`,
+ )
}
-function response(entry: DavEntry): string {
- return `
- ${escapeXml(entry.href)}
+export function proppatchMultistatus(href: string, properties: DavPropertyName[], status = 'HTTP/1.1 200 OK'): string {
+ return xmlDocument(`
+
+ ${escapeXml(href)}
- ${escapeXml(entry.displayName)}
- ${entry.createdAt.toISOString()}
- ${escapeXml(entry.etag)}
- ${entry.collection ? '' : ''}
- ${entry.contentLength}
- ${escapeXml(entry.contentType)}
- ${entry.updatedAt.toUTCString()}
-
-
+${properties.map((property) => ` ${emptyPropertyXml(property)}`).join('\n')}
- HTTP/1.1 200 OK
+ ${status}
- `
+
+`)
+}
+
+export function errorXml(precondition: string, message?: string): string {
+ const description = message ? `\n ${escapeXml(message)}` : ''
+ return xmlDocument(`
+ ${description}
+`)
+}
+
+export function lockDiscoveryXml(lock: DavLock): string {
+ return xmlDocument(`
+
+${activeLockXml(lock)}
+
+`)
+}
+
+export function parsePropfindXml(body: string): PropfindRequest {
+ if (!body.trim()) return { mode: 'allprop', properties: [], include: [] }
+ const root = parseXmlElement(body)
+ requireElement(root, DAV_NAMESPACE, 'propfind')
+
+ const children = elementChildren(root)
+ const prop = children.find((child) => isElement(child, DAV_NAMESPACE, 'prop'))
+ const propname = children.find((child) => isElement(child, DAV_NAMESPACE, 'propname'))
+ const allprop = children.find((child) => isElement(child, DAV_NAMESPACE, 'allprop'))
+ const include = children.find((child) => isElement(child, DAV_NAMESPACE, 'include'))
+ const selected = [prop, propname, allprop].filter(Boolean)
+ if (selected.length !== 1) throw new Error('PROPFIND must contain exactly one request type')
+
+ if (prop) return { mode: 'prop', properties: propertyNames(prop), include: [] }
+ if (propname) return { mode: 'propname', properties: [], include: [] }
+ return { mode: 'allprop', properties: [], include: include ? propertyNames(include) : [] }
+}
+
+export function parseProppatchXml(body: string): ProppatchOperation[] {
+ const root = parseXmlElement(body)
+ requireElement(root, DAV_NAMESPACE, 'propertyupdate')
+ const operations: ProppatchOperation[] = []
+ for (const instruction of elementChildren(root)) {
+ if (!isElement(instruction, DAV_NAMESPACE, 'set') && !isElement(instruction, DAV_NAMESPACE, 'remove')) {
+ throw new Error('PROPPATCH instructions must be set or remove')
+ }
+ const prop = elementChildren(instruction).find((child) => isElement(child, DAV_NAMESPACE, 'prop'))
+ if (!prop) throw new Error('PROPPATCH instruction missing prop')
+ for (const property of elementChildren(prop)) {
+ if (property.namespace === DAV_NAMESPACE) throw new Error('Protected DAV properties cannot be patched')
+ if (isElement(instruction, DAV_NAMESPACE, 'set')) {
+ operations.push({
+ action: 'set',
+ property: { ...toPropertyName(property), value: propertyXmlWithNamespace(property) },
+ })
+ } else {
+ operations.push({ action: 'remove', property: toPropertyName(property) })
+ }
+ }
+ }
+ if (operations.length === 0) throw new Error('PROPPATCH must change at least one property')
+ return operations
+}
+
+export function parseLockInfoXml(body: string): LockInfoRequest {
+ const root = parseXmlElement(body)
+ requireElement(root, DAV_NAMESPACE, 'lockinfo')
+ const lockscope = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'lockscope'))
+ const locktype = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'locktype'))
+ if (!lockscope || !locktype) throw new Error('LOCK request missing lockscope or locktype')
+ const exclusive = elementChildren(lockscope).some((child) => isElement(child, DAV_NAMESPACE, 'exclusive'))
+ const shared = elementChildren(lockscope).some((child) => isElement(child, DAV_NAMESPACE, 'shared'))
+ const write = elementChildren(locktype).some((child) => isElement(child, DAV_NAMESPACE, 'write'))
+ if (!exclusive || shared || !write) throw new Error('Only exclusive write locks are supported')
+ const owner = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'owner'))?.innerXml ?? ''
+ return { owner }
}
export function davEtag(id: string, size: number, updatedAt: Date): string {
return `"${id}-${size}-${updatedAt.getTime()}"`
}
+export function xmlResponse(body: string, status: number, headers?: Record): Response {
+ return new Response(body, {
+ status,
+ headers: { 'Content-Type': 'application/xml; charset=utf-8', ...headers },
+ })
+}
+
+function response(entry: DavEntry, request: PropfindRequest): string {
+ const properties = requestedProperties(entry, request)
+ const found = properties.filter((property) => propertyXml(entry, property))
+ const missing = properties.filter((property) => !propertyXml(entry, property))
+ const propstats = [
+ found.length > 0 ? propstat(entry, found, 'HTTP/1.1 200 OK', request.mode === 'propname') : '',
+ missing.length > 0 ? propstat(entry, missing, 'HTTP/1.1 404 Not Found', true) : '',
+ ]
+ .filter(Boolean)
+ .join('\n')
+
+ return `
+ ${escapeXml(entry.href)}
+${propstats}
+ `
+}
+
+function propstat(entry: DavEntry, properties: DavPropertyName[], status: string, namesOnly: boolean): string {
+ return `
+
+${properties.map((property) => ` ${namesOnly ? emptyPropertyXml(property) : propertyXml(entry, property)}`).join('\n')}
+
+ ${status}
+ `
+}
+
+function requestedProperties(entry: DavEntry, request: PropfindRequest): DavPropertyName[] {
+ if (request.mode === 'prop') return request.properties
+ const all = [...livePropertyNames(), ...entry.deadProperties.map(({ namespace, name }) => ({ namespace, name }))]
+ if (request.mode === 'propname') return uniqueProperties(all)
+ return uniqueProperties([...all, ...request.include])
+}
+
+function livePropertyNames(): DavPropertyName[] {
+ return [
+ { namespace: DAV_NAMESPACE, name: 'displayname' },
+ { namespace: DAV_NAMESPACE, name: 'creationdate' },
+ { namespace: DAV_NAMESPACE, name: 'getetag' },
+ { namespace: DAV_NAMESPACE, name: 'resourcetype' },
+ { namespace: DAV_NAMESPACE, name: 'getcontentlength' },
+ { namespace: DAV_NAMESPACE, name: 'getcontenttype' },
+ { namespace: DAV_NAMESPACE, name: 'getlastmodified' },
+ { namespace: DAV_NAMESPACE, name: 'supportedlock' },
+ { namespace: DAV_NAMESPACE, name: 'lockdiscovery' },
+ ]
+}
+
+function propertyXml(entry: DavEntry, property: DavPropertyName): string {
+ if (property.namespace !== DAV_NAMESPACE) {
+ return entry.deadProperties.find((dead) => sameProperty(dead, property))?.value ?? ''
+ }
+ switch (property.name) {
+ case 'displayname':
+ return `${escapeXml(entry.displayName)}`
+ case 'creationdate':
+ return `${entry.createdAt.toISOString()}`
+ case 'getetag':
+ return `${escapeXml(entry.etag)}`
+ case 'resourcetype':
+ return `${entry.collection ? '' : ''}`
+ case 'getcontentlength':
+ return `${entry.contentLength}`
+ case 'getcontenttype':
+ return `${escapeXml(entry.contentType)}`
+ case 'getlastmodified':
+ return `${entry.updatedAt.toUTCString()}`
+ case 'supportedlock':
+ return `
+
+ `
+ case 'lockdiscovery':
+ return `
+${entry.locks.map(activeLockXml).join('\n')}
+ `
+ default:
+ return ''
+ }
+}
+
+function activeLockXml(lock: DavLock): string {
+ return `
+
+
+ ${escapeXml(lock.depth)}
+ ${lock.owner}
+ Second-${Math.max(0, Math.ceil((lock.expiresAt.getTime() - Date.now()) / 1000))}
+ ${escapeXml(lock.token)}
+ `
+}
+
+function emptyPropertyXml(property: DavPropertyName): string {
+ return property.namespace === DAV_NAMESPACE
+ ? ``
+ : ``
+}
+
+function uniqueProperties(properties: DavPropertyName[]): DavPropertyName[] {
+ const seen = new Set()
+ return properties.filter((property) => {
+ const key = `${property.namespace}\n${property.name}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+function sameProperty(a: DavPropertyName, b: DavPropertyName): boolean {
+ return a.namespace === b.namespace && a.name === b.name
+}
+
+interface XmlElement {
+ namespace: string
+ name: string
+ prefix: string
+ raw: string
+ innerXml: string
+ children: XmlElement[]
+}
+
+function parseXmlElement(xml: string): XmlElement {
+ const source = xml.replace(/<\?xml[^>]*>/i, '').trim()
+ const root: XmlElement = { namespace: '', name: '', prefix: '', raw: '', innerXml: '', children: [] }
+ const stack: Array }> = [
+ { ...root, start: 0, bodyStart: 0, namespaces: new Map([['D', DAV_NAMESPACE]]) },
+ ]
+ const tag =
+ /|<\?[\s\S]*?\?>|<\/\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)\s*>|<\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)([^>]*?)>/g
+ let match = tag.exec(source)
+ while (match) {
+ if (match[0].startsWith('