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('