mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-31 02:41:33 +08:00
* refactor(server): rename routes/ to http/ (clean-arch step 1) The HTTP delivery layer was already split per-resource; align the directory name with the hono-cf-clean-arch standard. Pure mechanical move via git mv; updates the three server-side importers (app.ts, image-hosting-domain middleware, openapi/downloader). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): add clean-arch backbone + migrate activity to a repo Introduce the composition root and dependency-injection seam: - usecases/ports.ts (barrel) + usecases/ports/<resource>.ts: framework-free port interfaces and DTOs - usecases/deps.ts: the Deps aggregate consumed via c.get('deps') - composition.ts: createDeps(platform) — the only place adapters are built - app.ts sets deps in request context after platform middleware First adapter: adapters/repos/activity.ts (ActivityRepo) replaces services/activity.ts. All 14 call sites rewired (routes use c.get('deps').activity.*; auth.ts and transitional services construct the repo from db). DTOs are now plain shapes, not drizzle $inferSelect. Behavior-preserving: typecheck + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageRepo + migration tracker services/storage.ts -> adapters/repos/storage.ts (StorageRepo). All 14 callers rewired (http/middleware via c.get('deps').storages.*; transitional services via createStorageRepo(db)). Port DTO reuses the shared Storage contract with Date timestamps; the S3-credential 'Storage' type alias across 9 files now points at StorageRecord. Data-layer test moved next to the repo. Adds docs/clean-arch-migration.md as the living progress tracker. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract Profile/Announcement/Notification repos - profile -> ProfileRepo; the pure buildBreadcrumb moves to domain/breadcrumb.ts - announcement -> AnnouncementRepo; notification -> NotificationRepo - All callers rewired (routes via c.get('deps').*; auth.ts + services via create<X>Repo(db)); data-layer tests moved next to their repos - Test infra: createApp accepts an optional deps; createTestApp returns deps so tests fake a port by spying on testApp.deps.* (events SSE failure test no longer spies the service module) typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract OrgRepo (authz) + InviteRepo - org -> OrgRepo (findPersonalOrg/getMemberRole/canReadOrg/canWriteToOrg/ isPersonalOrg); rewired across 4 routes + 2 auth middlewares + auth.ts - invite -> InviteRepo; rewired invite-codes route + auth.ts - data/unit tests for org & invite moved next to their repos typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract BackgroundJobRepo (+ BackgroundJobError to ports) background-jobs -> adapters/repos/background-job.ts. The BackgroundJobError (caught by http for status mapping) moves to usecases/ports per the standard. Rewired: background-jobs route + events SSE (deps) + archive-processing (transitional repo). Unit + data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract QuotaRepo from effective-quota The foundational quota leaf. effective-quota.ts -> adapters/repos/quota.ts (QuotaRepo); the pure currentTrafficPeriod moves to domain/quota.ts; DTOs (EffectiveQuota, CurrentStoragePlan) move to ports. Rewired 14 callers (http -> deps.quota; services/auth/entry-node/workers.scheduled -> createQuotaRepo). scheduled-worker test now mocks the adapter (createQuotaRepo) instead of the service module. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract TeamRepo + TeamInviteRepo team -> adapters/repos/team.ts (TeamRepo; composes QuotaRepo for quota totals); team-invite -> adapters/repos/team-invite.ts. teams-admin + teams routes use c.get('deps').{teams,teamInvites}. Data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(arch): enforce clean architecture via dependency-cruiser (ratchet) in CI Adds .dependency-cruiser.cjs with the full hono-cf-clean-arch rule set and wires pnpm lint:arch into CI. The drizzle-only-in-repos rule uses a shrinking MIGRATION_PENDING allowlist so it passes today while still enforcing every already-migrated layer; each future migration commit removes an entry. platform/ (Database driver type) and auth.ts are permanent named exceptions. Currently green: 222 modules / 926 deps, 0 violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): combine user + org-entitlements into UserAdminRepo Resolves the pre-existing user <-> org-entitlements import cycle by merging both into adapters/repos/user-admin.ts (UserAdminRepo); shared types (UserWithOrg, QuotaEntitlementItem, UserOperationFailure, entitlement inputs) move to ports. users + teams-admin routes use c.get('deps').userAdmin. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract SiteInvitationRepo site-invitations -> adapters/repos/site-invitations.ts. Route uses c.get('deps').siteInvitations; the email helper now receives siteName from the handler (http stays out of adapters); auth.ts uses the repo. Result-type unions moved to ports. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cf): fix storages.cf-test seed after StorageRepo extraction cf-tests are excluded from typecheck; biome had pruned the transiently-unused createStorageRepo import during the storage migration. Restore the import and convert the platform.db seed calls. test:cf green (57 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): introduce BDD-lite spec/ + spec<->test traceability lint Adds the standard's product-spec layer: - spec/*.feature (Gherkin, no Cucumber runner) — one per capability, scenarios tagged @<capability>/<slug> + layer; spec/README.md documents the convention - [spec: <id>] breadcrumbs on home tests - scripts/lint-spec.mjs + pnpm lint:spec (wired into CI): every scenario id must have a referencing test and every breadcrumb must match a scenario Specced: storages, announcements, notifications, invite-codes, site-invitations (41 scenarios, all traced). Specs grow per capability as the migration proceeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract changelog + cf-custom-hostnames providers Establishes adapters/providers/. changelog (GitHub releases/CHANGELOG) and cf-custom-hostnames (CF for SaaS) move to adapters/providers/ behind ChangelogProvider / CfHostnamesProvider ports (CfConflictError -> ports). system + ihost-config routes use c.get('deps').{changelog,cfHostnames}. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move db-transaction -> db/, path-template -> lib/ Two framework-free utilities leave services/ for their proper homes: db/transaction.ts (the drizzle batch/transaction helper) and lib/path-template.ts (object-key builder). Importers updated. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate licensing subsystem drizzle to repos license-state -> adapters/repos/license-binding.ts (LicenseBindingRepo); instance-id + instance-info DB reads -> adapters/repos/instance.ts (InstanceRepo). licensing/ (has-feature, refresh, entitlement, instance-info) now uses the repos and imports no drizzle, so ^server/licensing leaves the dependency-cruiser ratchet. licensing-admin route uses c.get('deps').{licenseBinding,instance}; service callers construct the repos; instance-telemetry test mocks the adapter. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move S3Service to adapters/gateways behind S3Gateway port Establishes adapters/gateways/ + deps.s3. S3Service -> adapters/gateways/s3.ts (implements S3Gateway; S3StorageCredentials -> ports). A thin services/s3.ts re-export shim keeps the http routes (objects/webdav/ihost/share-utils) and the 21 prototype-spy tests working unchanged until those routes migrate to deps.s3; s3-dependent services can now move to usecases using deps.s3. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from me route (avatar -> ProfileRepo) ProfileRepo gains setAvatar; the /api/me avatar handlers use c.get('deps').profiles instead of inline user-table updates. 'me' leaves the dependency-cruiser ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from quotas route (-> QuotaRepo.listOrgQuotaOverview) The admin quota-overview join moves into QuotaRepo; the route uses c.get('deps').quota. 'quotas' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): SystemOptionsRepo drains auth-providers/system/email-config routes New adapters/repos/system-options.ts (key-value access to systemOptions) + deps.systemOptions. auth-providers, system, email-config routes drop inline drizzle and use c.get('deps').systemOptions; all three leave the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from teams route (logo -> TeamRepo.setLogo) TeamRepo gains setLogo; teams route uses c.get('deps').teams for logo set/clear and drops its dead db locals. 'teams' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from ihost-config (-> ImageHostingConfigRepo) New adapters/repos/image-hosting-config.ts + deps.imageHostingConfigs. The ihost-config route's custom-domain CRUD uses c.get('deps').imageHostingConfigs (cf-hostnames already via deps). 'ihost-config' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): loadBindingState -> usecase, hasFeature/effectiveFeatures -> domain Finishes the feature-gate path: domain/licensing.ts (pure hasFeature/effectiveFeatures), usecases/licensing.ts (loadBindingState(deps) using LicenseBindingRepo + cert verify). licensing/has-feature.ts deleted. Rewired 10 callers (routes/middleware via c.get('deps'); services via createLicenseBindingRepo(db)). Tests retargeted to the new modules (domain + usecases licensing). typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageUsageRepo + storage-usage reservation usecase The quota-reservation crown dependency. adapters/repos/storage-usage.ts (StorageUsageRepo: rollbackReservations + reconcile); usecases/storage-usage.ts (reserveStorageUsage/withStorageUsageReservation/StorageUsageMutationContext taking {quota,storageUsage} deps); StorageQuotaExceededError -> ports. Rewired 9 callers (objects/webdav/ihost routes via c.get('deps'); matter/image-hosting/archive/purge/ save-to-drive via constructed repos). Unblocks the matter/image-hosting clusters. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate 5 leaf service clusters to clean-arch (parallel wave) Extracted 7 services via parallel agents on file-disjoint components: - instance-telemetry -> usecases/instance-telemetry (reuses instance + systemOptions ports) - image-upload -> adapters/gateways/image-upload (ImageUpload port, deps.imageUpload) - archive-jobs -> adapters/gateways/archive-jobs (ArchiveJobsGateway, deps.archiveJobs) - zip-compress + zip-extract -> adapters/gateways/zip + adapters/repos/zip (ZipGateway + ZipPlanRepo) - object-upload-sessions -> adapters/repos/object-upload-session (ObjectUploadSessionRepo) - purge -> usecases/purge (pure usecase over existing s3/storages/storageUsage) Routes (objects/teams/me/internal/background-jobs) now reach these via c.get('deps'); entry files + workers build deps via createDeps(platform). Barrels wired by hand. typecheck + lint:arch (240 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add quotas/profile/licensing feature specs + traceability 29 new scenarios traced to existing integration tests via [spec: id] breadcrumbs. lint:spec: 70 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate auth/webdav/cloud/branding/image-hosting clusters (parallel wave 2) 17 services extracted via 5 parallel agents on file-disjoint components: - auth-account: email->EmailGateway, share-notification->ShareNotificationRepo, member-count->MemberCountRepo, captcha->domain+usecase, signup-mode/team-count->usecases - webdav-middleware: api-keys/download-tokens gateways, webdav-state/webdav-path repos, webdav-xml->domain (pure) - cloud: licensing-cloud->LicensingCloudGateway, cloud-store/cloud-traffic-report/ remote-download-usage repos (cloud-traffic-metering + licensing-refresh-runner folded in) - branding: pure usecase over existing deps (no new port) - image-hosting: ImageHostingRepo 12 new deps fields wired by hand. WebDavMatterRow DTO moved into the webdav-path port (was importing services/matter, which cycled through the ports barrel); domain WebDavMatter dirtype widened to number|null to match the nullable column. Ratchet shrunk: ihost.ts + middleware/image-hosting-domain.ts no longer touch drizzle. services/ now 26->9 (matter crown). typecheck + lint:arch (261 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add users/audit/teams/avatar/background-jobs/events/health specs 64 new scenarios traced to existing integration tests. lint:spec: 133 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate share/save-to-drive/archive-processing/trash-retention (parallel wave 3) - share -> ShareRepo (+ domain/share, transitional ShareMatterRow DTO); shares.ts now holds ZERO drizzle (dropped from the ratchet) - save-to-drive -> pure usecase over deps (s3/storages/storageUsage/quota/activity/share) - archive-processing -> usecase + ArchiveTargetFolderRepo (archive-jobs gateway self-assembles its deps subset from platform to avoid a composition cycle) - trash-retention -> pure usecase purge gains deps.share for share cascade-delete. 2 new deps fields wired. services/ now 9->5 (matter, matter-name-conflict, downloads, s3 shim, site-public-origin remain). typecheck + lint:arch (265 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add branding/email-config/auth-providers/system/image-hosting/webdav/quota-store specs 128 new scenarios traced to existing integration tests. lint:spec: 261 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the matter keystone + site-public-origin (wave 4) The crown. matter (644 lines, 17 exports) -> adapters/repos/matter.ts (MatterRepo: full drizzle CRUD + conflict resolution) + usecases/matter.ts (confirmUpload quota-guarded) + usecases/ports/matter.ts (Matter DTO + NameConflictError); matter-name-conflict -> domain. Fan-in of 10 rewired: objects/shares/trash routes now hold ZERO matter drizzle (via deps.matter); webdav + archive-processing/purge/save-to-drive/trash-retention usecases + zip/webdav-path repos repointed. site-public-origin -> domain (pure helpers) + usecase over deps.systemOptions. services/ now 5->2 (only downloads + the s3 shim remain). 1 new deps field (matter). typecheck + lint:arch (268 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add redirect + download-tasks specs 44 new scenarios traced to existing integration tests. lint:spec: 305 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate downloads (remote-download) cluster (wave 5) downloads/{core,mappers,types} (915 lines) -> adapters/repos/{downloader,download-task} (DownloaderRepo + DownloadTaskRepo) + usecases/downloads.ts (assignment + task state machine + remote-download credit billing) + usecases/ports/downloads.ts (DownloadError + DTOs). Rewired download-tasks/downloaders/events routes + objects.ts upload handlers to c.get('deps'). 2 new deps fields. services/ now down to ONLY the s3 shim. typecheck + lint:arch (268 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add shares spec (32 scenarios) lint:spec: 337 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): delete the s3 shim — services/ is empty, clean-arch complete Routed all 20 S3 call-sites in http (objects/webdav routes + share-utils consumers shares/redirect/ihost/image-hosting-domain) onto c.get('deps').s3; webdav's no-c helpers take an S3Gateway param. Repointed 17 test files off the shim onto adapters/gateways/s3. Deleted server/services/s3.ts — server/services/ is now empty and gone. Ratchet: dropped ^server/services (fully migrated); no-circular now fully enforced with no path exemptions. MIGRATION_PENDING is down to 2 deliberately-deferred files (http/webdav.ts listDescendants, middleware/auth.ts session lookup). Also adds the objects spec (39 scenarios) -> 376 scenarios across 26 capabilities. Final gates: typecheck + lint:arch (267 modules, no cycles) + lint:spec (376) + lint + 3810 tests + 57 cf-tests all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the last 2 ratchet files — architecture fully locked webdav.ts + middleware/auth.ts were the last files touching drizzle outside repos. - WebDAV: listDescendants/PROPPATCH-touch/PUT-overwrite/COPY-rollback + Basic-Auth username check moved to MatterRepo.{listActiveDescendants,trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesUsername}. webdav.ts now imports no drizzle. - Auth middleware: disabled-user (banned) check -> deps.userAdmin.isBanned. Ratchet (MIGRATION_PENDING) is now empty and removed. no-circular + drizzle-only-in-repos are fully enforced with zero exemptions; only platform/, test/, auth.ts remain as permanent named exceptions. New methods covered by existing real-D1 webdav/auth integration tests. typecheck + lint:arch (267 modules) + lint:spec (376) + lint + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): spec the 4 remaining admin/auth capabilities Closes the spec gaps for capabilities that had routes+tests but no .feature: image-hosting-config (domain/CF custom-hostname admin), licensing-admin (cloud pairing/binding/refresh), teams-admin (team admin + entitlements), auth-username (username sign-up). 42 new scenarios traced to existing integration tests. lint:spec: 418 scenarios across 30 capabilities, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(matter): listActiveDescendants uses exact-prefix (SUBSTR) not LIKE Folder names can contain '_'/'%', which LIKE treats as wildcards and would over-match descendants in WebDAV recursive COPY/MOVE. Reuse the repo's existing descendantParentCondition (SUBSTR), consistent with getDescendants/cascadeParentPath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): address review follow-ups (DTO dedupe, composition, dead locals) - Dedupe transitional DTOs: ShareMatterRow + WebDavMatterRow -> the canonical Matter port DTO (removes hand-copied duplicates + schema-drift risk; no cycle reintroduced). - composition.ts: hoist shared stateless instances (one s3/storages/systemOptions instead of constructing duplicates inline). - Remove the 21 dead 'const db = c.get(platform).db' locals -> biome warning-free. typecheck + lint:arch (267 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): dissolve server/licensing into domain + usecases layers server/licensing/ was a feature-grouped dir outside the layer taxonomy — its 3 orchestration files imported adapters directly, escaping usecases-no-infrastructure. Now classified + enforced: - public-keys -> domain/license-keys (pure) - verify + cloud-event-token -> usecases/license-certificate (paseto/zod crypto helpers) - entitlement/instance-info/refresh -> deps-first usecases (license-entitlement, instance-info, license-refresh), using existing deps.{licenseBinding,instance,licensingCloud} 11 consumers rewired to deps; dead db param dropped from runLicensingRefresh. No barrel changes. server/licensing/ deleted — every server file now sits in an enforced layer (or a named exception: platform/test/auth.ts/lib/middleware). typecheck + lint:arch (266 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import { DirType } from '@shared/constants'
|
|
import {
|
|
DAV_NAMESPACE,
|
|
type DavDeadProperty,
|
|
type DavLock,
|
|
type DavPropertyName,
|
|
davEtag,
|
|
matterHref,
|
|
type WebDavMatter,
|
|
type WebDavWorkspace,
|
|
workspaceHref,
|
|
} from './webdav'
|
|
|
|
export { DAV_NAMESPACE, davEtag }
|
|
|
|
export interface DavEntry {
|
|
href: string
|
|
displayName: string
|
|
collection: boolean
|
|
contentType: string
|
|
contentLength: number
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
etag: string
|
|
deadProperties: DavDeadProperty[]
|
|
locks: DavLock[]
|
|
}
|
|
|
|
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: stableDate,
|
|
updatedAt: stableDate,
|
|
etag: davEtag(workspace.id, 0, stableDate),
|
|
deadProperties,
|
|
locks,
|
|
}
|
|
}
|
|
|
|
export function mountRootEntry(): DavEntry {
|
|
const stableDate = new Date(0)
|
|
return {
|
|
href: '/dav/',
|
|
displayName: 'dav',
|
|
collection: true,
|
|
contentType: 'httpd/unix-directory',
|
|
contentLength: 0,
|
|
createdAt: stableDate,
|
|
updatedAt: stableDate,
|
|
etag: davEtag('mount-root', 0, stableDate),
|
|
deadProperties: [],
|
|
locks: [],
|
|
}
|
|
}
|
|
|
|
export function matterEntry(
|
|
workspace: WebDavWorkspace,
|
|
matter: WebDavMatter,
|
|
deadProperties: DavDeadProperty[],
|
|
locks: DavLock[],
|
|
): DavEntry {
|
|
const collection = matter.dirtype !== DirType.FILE
|
|
return {
|
|
href: collection ? `${matterHref(workspace, matter)}/` : matterHref(workspace, matter),
|
|
displayName: matter.name,
|
|
collection,
|
|
contentType: collection ? 'httpd/unix-directory' : matter.type,
|
|
contentLength: matter.size ?? 0,
|
|
createdAt: matter.createdAt,
|
|
updatedAt: matter.updatedAt,
|
|
etag: davEtag(matter.id, matter.size ?? 0, matter.updatedAt),
|
|
deadProperties,
|
|
locks,
|
|
}
|
|
}
|
|
|
|
export function multistatus(entries: DavEntry[], request: PropfindRequest): string {
|
|
return xmlDocument(
|
|
`<D:multistatus xmlns:D="DAV:">\n${entries.map((entry) => response(entry, request)).join('\n')}\n</D:multistatus>`,
|
|
)
|
|
}
|
|
|
|
export function proppatchMultistatus(href: string, properties: DavPropertyName[], status = 'HTTP/1.1 200 OK'): string {
|
|
return xmlDocument(`<D:multistatus xmlns:D="DAV:">
|
|
<D:response>
|
|
<D:href>${escapeXml(href)}</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
${properties.map((property) => ` ${emptyPropertyXml(property)}`).join('\n')}
|
|
</D:prop>
|
|
<D:status>${status}</D:status>
|
|
</D:propstat>
|
|
</D:response>
|
|
</D:multistatus>`)
|
|
}
|
|
|
|
export function errorXml(precondition: string, message?: string): string {
|
|
const description = message ? `\n <D:responsedescription>${escapeXml(message)}</D:responsedescription>` : ''
|
|
return xmlDocument(`<D:error xmlns:D="DAV:">
|
|
<D:${precondition}/>${description}
|
|
</D:error>`)
|
|
}
|
|
|
|
export function lockDiscoveryXml(lock: DavLock): string {
|
|
return xmlDocument(`<D:prop xmlns:D="DAV:">
|
|
<D:lockdiscovery>
|
|
${activeLockXml(lock)}
|
|
</D:lockdiscovery>
|
|
</D:prop>`)
|
|
}
|
|
|
|
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 xmlResponse(body: string, status: number, headers?: Record<string, string>): 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 ` <D:response>
|
|
<D:href>${escapeXml(entry.href)}</D:href>
|
|
${propstats}
|
|
</D:response>`
|
|
}
|
|
|
|
function propstat(entry: DavEntry, properties: DavPropertyName[], status: string, namesOnly: boolean): string {
|
|
return ` <D:propstat>
|
|
<D:prop>
|
|
${properties.map((property) => ` ${namesOnly ? emptyPropertyXml(property) : propertyXml(entry, property)}`).join('\n')}
|
|
</D:prop>
|
|
<D:status>${status}</D:status>
|
|
</D:propstat>`
|
|
}
|
|
|
|
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 `<D:displayname>${escapeXml(entry.displayName)}</D:displayname>`
|
|
case 'creationdate':
|
|
return `<D:creationdate>${entry.createdAt.toISOString()}</D:creationdate>`
|
|
case 'getetag':
|
|
return `<D:getetag>${escapeXml(entry.etag)}</D:getetag>`
|
|
case 'resourcetype':
|
|
return `<D:resourcetype>${entry.collection ? '<D:collection/>' : ''}</D:resourcetype>`
|
|
case 'getcontentlength':
|
|
return `<D:getcontentlength>${entry.contentLength}</D:getcontentlength>`
|
|
case 'getcontenttype':
|
|
return `<D:getcontenttype>${escapeXml(entry.contentType)}</D:getcontenttype>`
|
|
case 'getlastmodified':
|
|
return `<D:getlastmodified>${entry.updatedAt.toUTCString()}</D:getlastmodified>`
|
|
case 'supportedlock':
|
|
return `<D:supportedlock>
|
|
<D:lockentry><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
|
|
</D:supportedlock>`
|
|
case 'lockdiscovery':
|
|
return `<D:lockdiscovery>
|
|
${entry.locks.map(activeLockXml).join('\n')}
|
|
</D:lockdiscovery>`
|
|
default:
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function activeLockXml(lock: DavLock): string {
|
|
return ` <D:activelock>
|
|
<D:locktype><D:write/></D:locktype>
|
|
<D:lockscope><D:exclusive/></D:lockscope>
|
|
<D:depth>${escapeXml(lock.depth)}</D:depth>
|
|
<D:owner>${ownerXml(lock.owner)}</D:owner>
|
|
<D:timeout>Second-${Math.max(0, Math.ceil((lock.expiresAt.getTime() - Date.now()) / 1000))}</D:timeout>
|
|
<D:locktoken><D:href>${escapeXml(lock.token)}</D:href></D:locktoken>
|
|
</D:activelock>`
|
|
}
|
|
|
|
function ownerXml(owner: string): string {
|
|
return /<\s*[A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?[\s>/]/.test(owner) ? owner : escapeXml(unescapeXml(owner))
|
|
}
|
|
|
|
function unescapeXml(value: string): string {
|
|
return value
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'")
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('&', '&')
|
|
}
|
|
|
|
function emptyPropertyXml(property: DavPropertyName): string {
|
|
return property.namespace === DAV_NAMESPACE
|
|
? `<D:${property.name}/>`
|
|
: `<Z:${property.name} xmlns:Z="${escapeXml(property.namespace)}"/>`
|
|
}
|
|
|
|
function uniqueProperties(properties: DavPropertyName[]): DavPropertyName[] {
|
|
const seen = new Set<string>()
|
|
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<XmlElement & { start: number; bodyStart: number; namespaces: Map<string, string> }> = [
|
|
{ ...root, start: 0, bodyStart: 0, namespaces: new Map([['D', DAV_NAMESPACE]]) },
|
|
]
|
|
const tag =
|
|
/<!--[\s\S]*?-->|<\?[\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('<!--') || match[0].startsWith('<?')) {
|
|
match = tag.exec(source)
|
|
continue
|
|
}
|
|
|
|
if (match[2]) {
|
|
const current = stack.pop()
|
|
if (!current || stack.length === 0) throw new Error('Invalid XML')
|
|
const closePrefix = match[1]?.slice(0, -1) ?? ''
|
|
if (current.prefix !== closePrefix || current.name !== match[2]) throw new Error('Invalid XML')
|
|
current.raw = source.slice(current.start, match.index + match[0].length)
|
|
current.innerXml = source.slice(current.bodyStart, match.index)
|
|
stack.at(-1)?.children.push(current)
|
|
match = tag.exec(source)
|
|
continue
|
|
}
|
|
|
|
const prefix = match[3]?.slice(0, -1) ?? ''
|
|
const name = match[4]
|
|
const rawAttributes = match[5] ?? ''
|
|
const selfClosing = /\/\s*$/.test(rawAttributes)
|
|
const namespaces = new Map(stack.at(-1)?.namespaces)
|
|
for (const ns of rawAttributes.matchAll(/\s+xmlns(?::([A-Za-z_][\w.-]*))?=["']([^"']+)["']/g)) {
|
|
namespaces.set(ns[1] ?? '', ns[2])
|
|
}
|
|
const namespace = namespaces.get(prefix) ?? (prefix || namespaces.has('') ? '' : DAV_NAMESPACE)
|
|
const element = {
|
|
namespace,
|
|
name,
|
|
prefix,
|
|
raw: selfClosing ? match[0] : '',
|
|
innerXml: '',
|
|
children: [],
|
|
start: match.index,
|
|
bodyStart: match.index + match[0].length,
|
|
namespaces,
|
|
}
|
|
if (selfClosing) {
|
|
stack.at(-1)?.children.push(element)
|
|
} else {
|
|
stack.push(element)
|
|
}
|
|
match = tag.exec(source)
|
|
}
|
|
if (stack.length !== 1 || stack[0].children.length !== 1) throw new Error('Invalid XML')
|
|
return stack[0].children[0]
|
|
}
|
|
|
|
function requireElement(element: XmlElement, namespace: string, name: string): void {
|
|
if (!isElement(element, namespace, name)) throw new Error(`Expected ${name}`)
|
|
}
|
|
|
|
function isElement(element: XmlElement, namespace: string, name: string): boolean {
|
|
return element.namespace === namespace && element.name === name
|
|
}
|
|
|
|
function elementChildren(element: XmlElement): XmlElement[] {
|
|
return element.children
|
|
}
|
|
|
|
function propertyNames(element: XmlElement): DavPropertyName[] {
|
|
return elementChildren(element).map(toPropertyName)
|
|
}
|
|
|
|
function toPropertyName(element: XmlElement): DavPropertyName {
|
|
return { namespace: element.namespace, name: element.name }
|
|
}
|
|
|
|
function propertyXmlWithNamespace(element: XmlElement): string {
|
|
if (element.namespace === DAV_NAMESPACE) return element.raw
|
|
const insertion = element.prefix
|
|
? ` xmlns:${element.prefix}="${escapeXml(element.namespace)}"`
|
|
: ` xmlns="${escapeXml(element.namespace)}"`
|
|
if (element.prefix) {
|
|
if (element.raw.includes(`xmlns:${element.prefix}=`)) return element.raw
|
|
return element.raw.replace(/(<[^\s>/]+)(\s|>|\/>)/, `$1${insertion}$2`)
|
|
}
|
|
if (element.raw.includes('xmlns=')) return element.raw
|
|
return element.raw.replace(/(<[^\s>/]+)(\s|>|\/>)/, `$1${insertion}$2`)
|
|
}
|
|
|
|
function xmlDocument(body: string): string {
|
|
return `<?xml version="1.0" encoding="utf-8"?>\n${body}`
|
|
}
|
|
|
|
function escapeXml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
}
|