feat(webdav): verify derived domains before publishing

This commit is contained in:
saltbo
2026-07-20 18:54:12 -04:00
parent 7fe16f3ed7
commit e761b6aec7
27 changed files with 672 additions and 38 deletions
+25 -2
View File
@@ -202,11 +202,18 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Keep post-deploy hooks inside the repository deploy command so
# GitHub Actions and Cloudflare Workers Builds can share the same path.
run: pnpm run deploy
- name: Clear WebDAV domain readiness
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', ''), ('webdav_verified_at', ''), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
- name: Reconcile fixed WebDAV domain
id: webdav
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -214,3 +221,19 @@ jobs:
OUTPUT=$(node scripts/sync-cloudflare-webdav.mjs)
echo "$OUTPUT"
printf '%s\n' "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
- name: Record WebDAV domain readiness
if: steps.webdav.outputs.origin != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WEBDAV_ORIGIN: ${{ steps.webdav.outputs.origin }}
run: |
if [[ ! "$WEBDAV_ORIGIN" =~ ^https://[A-Za-z0-9.-]+$ ]]; then
echo "::error::Invalid WebDAV origin returned by deployment sync"
exit 1
fi
VERIFIED_AT=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', '$WEBDAV_ORIGIN'), ('webdav_verified_at', '$VERIFIED_AT'), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
echo "WebDAV readiness recorded for $WEBDAV_ORIGIN" >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -117,7 +117,7 @@ Deploy via GitHub Actions with zero server management. Free tier covers personal
After initial setup, the workflow runs automatically every time you sync your fork with the latest release.
Dedicated WebDAV domain: configure the site's **Public URL** in Admin Settings and extend the API token with **Transform Rules:Edit**. For a primary Worker Custom Domain such as `files.example.com`, the deployment workflow automatically attaches `dav.files.example.com` and manages the root-to-`/dav` rewrite. The original `/dav/` endpoint remains available. See [WebDAV custom domains](docs/webdav-custom-domain.md).
Dedicated WebDAV domain: configure the site's **Public URL** in Admin Settings and extend the API token with **Transform Rules:Edit**. For a primary Worker Custom Domain such as `files.example.com`, the deployment workflow automatically attaches and verifies `dav.files.example.com`, manages the root-to-`/dav` rewrite, and records its readiness. Other deployments can verify their manually configured DNS/proxy from **Admin Settings → WebDAV**. Until verification succeeds, ZPan advertises the original `/dav/` endpoint. See [WebDAV custom domains](docs/webdav-custom-domain.md).
### AWS Lambda
+146 -2
View File
@@ -504,6 +504,27 @@ func (e SiteBrandingThemeMode) Valid() bool {
}
}
// Defines values for WebDavVerificationStatus.
const (
WebDavVerificationStatusFailed WebDavVerificationStatus = "failed"
WebDavVerificationStatusReady WebDavVerificationStatus = "ready"
WebDavVerificationStatusUnverified WebDavVerificationStatus = "unverified"
)
// Valid indicates whether the value is a known member of the WebDavVerificationStatus enum.
func (e WebDavVerificationStatus) Valid() bool {
switch e {
case WebDavVerificationStatusFailed:
return true
case WebDavVerificationStatusReady:
return true
case WebDavVerificationStatusUnverified:
return true
default:
return false
}
}
// Defines values for ChangeEmail200JSONResponseBodyMessage.
const (
ChangeEmail200JSONResponseBodyMessageEmailUpdated ChangeEmail200JSONResponseBodyMessage = "Email updated"
@@ -1361,13 +1382,13 @@ func (e UpdateStorageJSONBodyStatus) Valid() bool {
// Defines values for CancelOrderJSONBodyStatus.
const (
Canceled CancelOrderJSONBodyStatus = "canceled"
CancelOrderJSONBodyStatusCanceled CancelOrderJSONBodyStatus = "canceled"
)
// Valid indicates whether the value is a known member of the CancelOrderJSONBodyStatus enum.
func (e CancelOrderJSONBodyStatus) Valid() bool {
switch e {
case Canceled:
case CancelOrderJSONBodyStatusCanceled:
return true
default:
return false
@@ -2488,6 +2509,16 @@ type SiteSettings struct {
Identity SiteIdentitySettings `json:"identity"`
Quotas SiteQuotaSettings `json:"quotas"`
Registration SiteRegistrationSettings `json:"registration"`
Webdav SiteWebDavSettings `json:"webdav"`
}
// SiteWebDavSettings defines model for SiteWebDavSettings.
type SiteWebDavSettings struct {
CandidateUrl *string `json:"candidateUrl"`
Error *string `json:"error"`
LastVerifiedAt *time.Time `json:"lastVerifiedAt"`
PathUrl string `json:"pathUrl"`
Status WebDavVerificationStatus `json:"status"`
}
// Storage defines model for Storage.
@@ -2630,6 +2661,9 @@ type User struct {
Username *string `json:"username,omitempty"`
}
// WebDavVerificationStatus defines model for WebDavVerificationStatus.
type WebDavVerificationStatus string
// BanUserJSONBody defines parameters for BanUser.
type BanUserJSONBody struct {
// BanExpiresIn The number of seconds until the ban expires
@@ -5371,6 +5405,9 @@ type ClientInterface interface {
UpdateSiteRegistration(ctx context.Context, body UpdateSiteRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// VerifySiteWebDav request
VerifySiteWebDav(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListStorages request
ListStorages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -8676,6 +8713,18 @@ func (c *Client) UpdateSiteRegistration(ctx context.Context, body UpdateSiteRegi
return c.Client.Do(req)
}
func (c *Client) VerifySiteWebDav(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewVerifySiteWebDavRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) ListStorages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListStoragesRequest(c.Server)
if err != nil {
@@ -16796,6 +16845,33 @@ func NewUpdateSiteRegistrationRequestWithBody(server string, contentType string,
return req, nil
}
// NewVerifySiteWebDavRequest generates requests for VerifySiteWebDav
func NewVerifySiteWebDavRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/site/settings/webdav/verification")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewListStoragesRequest generates requests for ListStorages
func NewListStoragesRequest(server string) (*http.Request, error) {
var err error
@@ -19244,6 +19320,9 @@ type ClientWithResponsesInterface interface {
UpdateSiteRegistrationWithResponse(ctx context.Context, body UpdateSiteRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateSiteRegistrationResponse, error)
// VerifySiteWebDavWithResponse request
VerifySiteWebDavWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*VerifySiteWebDavResponse, error)
// ListStoragesWithResponse request
ListStoragesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStoragesResponse, error)
@@ -26761,6 +26840,36 @@ func (r UpdateSiteRegistrationResponse) ContentType() string {
return ""
}
type VerifySiteWebDavResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *SiteWebDavSettings
}
// Status returns HTTPResponse.Status
func (r VerifySiteWebDavResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r VerifySiteWebDavResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r VerifySiteWebDavResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type ListStoragesResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -30437,6 +30546,15 @@ func (c *ClientWithResponses) UpdateSiteRegistrationWithResponse(ctx context.Con
return ParseUpdateSiteRegistrationResponse(rsp)
}
// VerifySiteWebDavWithResponse request returning *VerifySiteWebDavResponse
func (c *ClientWithResponses) VerifySiteWebDavWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*VerifySiteWebDavResponse, error) {
rsp, err := c.VerifySiteWebDav(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParseVerifySiteWebDavResponse(rsp)
}
// ListStoragesWithResponse request returning *ListStoragesResponse
func (c *ClientWithResponses) ListStoragesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStoragesResponse, error) {
rsp, err := c.ListStorages(ctx, reqEditors...)
@@ -41383,6 +41501,32 @@ func ParseUpdateSiteRegistrationResponse(rsp *http.Response) (*UpdateSiteRegistr
return response, nil
}
// ParseVerifySiteWebDavResponse parses an HTTP response from a VerifySiteWebDavWithResponse call
func ParseVerifySiteWebDavResponse(rsp *http.Response) (*VerifySiteWebDavResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &VerifySiteWebDavResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest SiteWebDavSettings
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseListStoragesResponse parses an HTTP response from a ListStoragesWithResponse call
func ParseListStoragesResponse(rsp *http.Response) (*ListStoragesResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+1 -1
View File
@@ -113,7 +113,7 @@ ZPan 并不打算成为:
完成初始设置后,每次你将 fork 与最新版本同步时,该工作流都会自动运行。
WebDAV 独立域名:先在管理后台设置站点的**对外访问地址**,并为 API Token 增加 **Transform Rules:Edit** 权限。若主站 Worker Custom Domain 为 `files.example.com`,部署流程会自动绑定 `dav.files.example.com`管理根路径到 `/dav` 的 rewrite原有 `/dav/` 入口仍然可用。详见 [WebDAV 自定义域名](../webdav-custom-domain.md)。
WebDAV 独立域名:先在管理后台设置站点的**对外访问地址**,并为 API Token 增加 **Transform Rules:Edit** 权限。若主站 Worker Custom Domain 为 `files.example.com`,部署流程会自动绑定并验证 `dav.files.example.com`,管理根路径到 `/dav` 的 rewrite,并记录可用状态。其他部署方式可在手动配置 DNS/代理后,通过**管理后台 → 设置 → WebDAV**完成验证。验证成功前,ZPan 会继续公布原有 `/dav/` 入口。详见 [WebDAV 自定义域名](../webdav-custom-domain.md)。
### AWS Lambda
+6 -2
View File
@@ -2,11 +2,13 @@
ZPan always serves WebDAV internally at `/dav`. The dedicated hostname is fixed by prepending `dav.` to the hostname in Admin Settings → Public URL. For example, `https://files.example.com` produces `https://dav.files.example.com/`. The original `/dav/` endpoint remains available.
Until that exact derived origin has been verified, the public `configz` document and user-facing WebDAV setup page continue to advertise the `/dav/` URL. Verification status is shown under **Admin Settings → WebDAV**. Changing Public URL invalidates the previous verification automatically.
## Cloudflare Workers
Set the primary site hostname as a Worker Custom Domain and configure the same origin as **Public URL** in ZPan Admin Settings. The existing `CLOUDFLARE_API_TOKEN` also needs `Transform Rules:Edit` for that zone.
The production deployment finds the primary Custom Domain already attached to the `zpan` Worker, derives its `dav.` hostname, creates a hostname-only Transform Rule, attaches the derived hostname as another Worker Custom Domain, and verifies the WebDAV authentication challenge. ZPan-owned rules use a `zpan_webdav_` ref prefix; the workflow does not replace or delete unrelated rules.
The production deployment finds the primary Custom Domain already attached to the `zpan` Worker, derives its `dav.` hostname, creates a hostname-only Transform Rule, attaches the derived hostname as another Worker Custom Domain, and verifies the WebDAV authentication challenge. After verification succeeds, the workflow records that exact origin in D1 so ZPan can advertise it. ZPan-owned rules use a `zpan_webdav_` ref prefix; the workflow does not replace or delete unrelated rules.
If the Worker has no primary Custom Domain, the deployment skips the dedicated hostname and `/dav/` remains available. If it has multiple possible primary Custom Domains, deployment fails instead of choosing one arbitrarily.
@@ -20,6 +22,8 @@ Configure the main origin as **Public URL** in ZPan Admin Settings. If it is `ht
2. Internally prefix every request path with `/dav` without returning a redirect.
3. Preserve the original `Host`, HTTP method, query, body, and WebDAV headers such as `Destination`, `If`, `Depth`, `Overwrite`, and `Lock-Token`.
For example, an external request to `/Workspace/file.txt` must reach ZPan as `/dav/Workspace/file.txt` while the request hostname remains `dav.example.com`. ZPan then emits root-relative WebDAV resource addresses such as `/Workspace/file.txt`.
After configuring DNS and the proxy, open **Admin Settings → WebDAV** and select **Verify domain**. The server sends an unauthenticated `OPTIONS` request and accepts the domain only when it receives ZPan's WebDAV authentication challenge. This verification works the same way for Docker, Lambda, Vercel, Netlify, Azure, Cloud Run, and other deployments; it does not configure the external proxy for you.
For example, an external request to `/Workspace/file.txt` must reach ZPan as `/dav/Workspace/file.txt` while the request hostname remains `dav.files.example.com`. ZPan then emits root-relative WebDAV resource addresses such as `/Workspace/file.txt`.
Without the dedicated proxy hostname, clients can still connect to `https://your-zpan.example/dav/`.
+3
View File
@@ -6,6 +6,7 @@ import {
syncCloudflareWebDav,
webDavHostname,
} from './cloudflare-webdav.mjs'
import { webDavOrigin } from './sync-cloudflare-webdav.mjs'
function envelope(result, init = {}) {
return Response.json({ success: true, errors: [], messages: [], result, ...init })
@@ -98,6 +99,8 @@ describe('Cloudflare WebDAV deployment sync', () => {
expect(rule.ref).toBe(managedRuleRef('dav.files.example.com'))
expect(rule.expression).toBe('http.host eq "dav.files.example.com"')
expect(rule.action_parameters.uri.path.expression).toBe('concat("/dav", http.request.uri.path)')
expect(webDavOrigin({ hostname: 'dav.files.example.com' })).toBe('https://dav.files.example.com')
expect(webDavOrigin({ hostname: null })).toBe('')
})
it('identifies the primary domain and excludes its fixed DAV companion', () => {
+8 -1
View File
@@ -1,18 +1,25 @@
import { appendFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { syncCloudflareWebDav } from './cloudflare-webdav.mjs'
export function webDavOrigin(result) {
return result.hostname ? new URL(`https://${result.hostname}`).origin : ''
}
async function main() {
const result = await syncCloudflareWebDav({
token: process.env.CLOUDFLARE_API_TOKEN,
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
})
const origin = webDavOrigin(result)
if (result.hostname) {
console.log(`WebDAV custom domain ready: https://${result.hostname}/`)
console.log(`WebDAV custom domain ready: ${origin}/`)
} else {
console.log('No primary Worker Custom Domain found; keeping /dav/')
}
console.log(`Removed stale WebDAV resources: rules=${result.removedRules} domains=${result.removedDomains}`)
if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `origin=${origin}\n`)
}
const entrypoint = path.resolve(process.argv[1] ?? '')
+12 -4
View File
@@ -18,11 +18,19 @@ describe('WebDAV public URL', () => {
expect(webDavMountPath('https://example.com/dav/workspace', 'https://example.com')).toBe('/dav')
})
it('returns the derived origin or the request-origin path fallback', () => {
expect(effectiveWebDavUrl('https://example.com/api/configz', 'https://example.com')).toBe(
'https://dav.example.com/',
it('publishes the derived origin only after that exact origin is verified', () => {
expect(effectiveWebDavUrl('https://example.com/api/configz', 'https://example.com', null)).toBe(
'https://example.com/dav/',
)
expect(
effectiveWebDavUrl('https://example.com/api/configz', 'https://example.com', 'https://dav.example.com'),
).toBe('https://dav.example.com/')
expect(
effectiveWebDavUrl('https://example.com/api/configz', 'https://example.com', 'https://dav.old.example.com'),
).toBe('https://example.com/dav/')
expect(effectiveWebDavUrl('https://pan.example.com/api/configz', undefined, null)).toBe(
'https://pan.example.com/dav/',
)
expect(effectiveWebDavUrl('https://pan.example.com/api/configz', undefined)).toBe('https://pan.example.com/dav/')
})
it('preserves protocol and port for deployments behind a local proxy', () => {
+15 -3
View File
@@ -1,6 +1,7 @@
import { normalizePublicOrigin } from './site-public-origin'
export type WebDavMountPath = '' | '/dav'
export const WEBDAV_AUTH_CHALLENGE = 'Basic realm="ZPan WebDAV"'
export function webDavPublicUrl(sitePublicOrigin: string | null | undefined): URL | null {
const origin = normalizePublicOrigin(sitePublicOrigin)
@@ -25,7 +26,18 @@ export function webDavMountPath(requestUrl: string, sitePublicOrigin: string | n
return isWebDavPublicRequest(requestUrl, sitePublicOrigin) ? '' : '/dav'
}
export function effectiveWebDavUrl(requestUrl: string, sitePublicOrigin: string | null | undefined): string {
const publicUrl = webDavPublicUrl(sitePublicOrigin)
return publicUrl ? `${publicUrl.origin}/` : new URL('/dav/', requestUrl).toString()
export function webDavPathUrl(requestUrl: string, sitePublicOrigin: string | null | undefined): string {
const origin = normalizePublicOrigin(sitePublicOrigin) ?? new URL(requestUrl).origin
return new URL('/dav/', `${origin}/`).toString()
}
export function effectiveWebDavUrl(
requestUrl: string,
sitePublicOrigin: string | null | undefined,
verifiedOrigin: string | null | undefined,
): string {
const publicUrl = webDavPublicUrl(sitePublicOrigin)
return publicUrl && publicUrl.origin === normalizePublicOrigin(verifiedOrigin)
? `${publicUrl.origin}/`
: webDavPathUrl(requestUrl, sitePublicOrigin)
}
+34 -4
View File
@@ -1,5 +1,5 @@
import { SignupMode } from '@shared/constants'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { adminHeaders, createTestApp } from '../../test/setup.js'
async function put(
@@ -25,7 +25,7 @@ describe('Site configuration API', () => {
expect(body).toMatchObject({
site: { name: 'ZPan', description: '', publicUrl: 'https://pan.example.com' },
auth: { captcha: { enabled: false }, providers: [] },
services: { webdav: { url: 'https://dav.pan.example.com/' } },
services: { webdav: { url: 'https://pan.example.com/dav/' } },
})
expect(body).toHaveProperty('branding')
})
@@ -42,10 +42,15 @@ describe('Site configuration API', () => {
identity: { name: 'ZPan', description: '' },
registration: { configuredMode: SignupMode.OPEN, effectiveMode: SignupMode.OPEN },
captcha: { enabled: false, secretConfigured: false },
webdav: {
pathUrl: expect.stringContaining('/dav/'),
candidateUrl: expect.stringContaining('dav.'),
status: 'unverified',
},
})
})
it('updates Public URL and configz derives the WebDAV domain from it [spec: system/webdav-url]', async () => {
it('publishes the derived WebDAV domain only after verification [spec: system/webdav-url]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -66,7 +71,32 @@ describe('Site configuration API', () => {
services: { webdav: { url: string } }
}
expect(config.site.publicUrl).toBe('https://files.example.com')
expect(config.services.webdav.url).toBe('https://dav.files.example.com/')
expect(config.services.webdav.url).toBe('https://files.example.com/dav/')
const probe = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="ZPan WebDAV"' },
}),
)
const verification = await app.request('/api/site/settings/webdav/verification', {
method: 'POST',
headers: admin,
})
probe.mockRestore()
expect(verification.status).toBe(200)
await expect(verification.json()).resolves.toMatchObject({ status: 'ready' })
const verifiedConfig = (await (await app.request('https://request.example.com/api/configz')).json()) as {
services: { webdav: { url: string } }
}
expect(verifiedConfig.services.webdav.url).toBe('https://dav.files.example.com/')
})
it('requires admin for WebDAV verification', async () => {
const { app } = await createTestApp()
const response = await app.request('/api/site/settings/webdav/verification', { method: 'POST' })
expect(response.status).toBe(401)
})
it('updates captcha as a group without returning its secret [spec: system/captcha-secret-private]', async () => {
+15
View File
@@ -5,6 +5,7 @@ import {
siteQuotaSettingsSchema,
siteRegistrationSettingsSchema,
siteSettingsSchema,
siteWebDavSettingsSchema,
updateSiteCaptchaSchema,
updateSiteIdentitySchema,
updateSiteQuotasSchema,
@@ -18,6 +19,7 @@ import {
updateSiteIdentity,
updateSiteQuotas,
updateSiteRegistration,
verifySiteWebDav,
} from '../../usecases/site/settings'
import { errorResponse, jsonBody, jsonContent } from '../openapi'
@@ -85,6 +87,16 @@ const updateQuotasRoute = createRoute({
responses: { 200: jsonContent(siteQuotaSettingsSchema, 'Updated quota settings') },
})
const verifyWebDavRoute = createRoute({
operationId: 'verifySiteWebDav',
summary: 'Verify the derived WebDAV domain',
tags: ['Site Settings'],
method: 'post',
path: '/webdav/verification',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(siteWebDavSettingsSchema, 'Current WebDAV verification status') },
})
function actor(c: { get(key: 'userId' | 'orgId'): string | null }) {
return { userId: c.get('userId')!, orgId: c.get('orgId')! }
}
@@ -103,3 +115,6 @@ export const siteSettings = new OpenAPIHono<Env>()
.openapi(updateQuotasRoute, async (c) =>
c.json(await updateSiteQuotas(c.get('deps'), actor(c), c.req.valid('json')), 200),
)
.openapi(verifyWebDavRoute, async (c) =>
c.json(await verifySiteWebDav(c.get('deps'), actor(c), c.req.url, fetch), 200),
)
+1 -1
View File
@@ -17,7 +17,7 @@ describe('[CF] System API', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as { site: { publicUrl: string }; services: { webdav: { url: string } } }
expect(body.site.publicUrl).toBe('https://pan.example.com')
expect(body.services.webdav.url).toBe('https://dav.pan.example.com/')
expect(body.services.webdav.url).toBe('https://pan.example.com/dav/')
})
it('does not expose the removed generic Options API', async () => {
+2 -3
View File
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
import { ApiKeyTemplate } from '../../shared/api-key-templates'
import { DirType, ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { encodeDavPathSegment, joinMatterPath, workspaceHref } from '../domain/webdav'
import { type WebDavMountPath, webDavPublicUrl } from '../domain/webdav-public-url'
import { WEBDAV_AUTH_CHALLENGE, type WebDavMountPath, webDavPublicUrl } from '../domain/webdav-public-url'
import {
type DavEntry,
davEtag,
@@ -59,7 +59,6 @@ import {
const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD'])
const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK'])
const WEBDAV_RESOURCE = 'webdav'
const WEBDAV_REALM = 'Basic realm="ZPan WebDAV"'
type DavContext = Context<Env>
type DavAuth = { userId: string }
@@ -93,7 +92,7 @@ async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
}
function unauthorized(): Response {
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': WEBDAV_REALM } })
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': WEBDAV_AUTH_CHALLENGE } })
}
function rateLimited(error: ApiKeyRateLimitError): Response {
+14 -1
View File
@@ -40,7 +40,7 @@ describe('getSiteConfig', () => {
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
},
auth: { signupMode: SignupMode.INVITE_ONLY, captcha: { enabled: false }, providers: [] },
services: { webdav: { url: 'https://dav.pan.example.com/' } },
services: { webdav: { url: 'https://pan.example.com/dav/' } },
})
})
@@ -58,6 +58,7 @@ describe('getSiteConfig', () => {
['site_name', 'My ZPan'],
['site_description', 'Files'],
['site_public_origin', 'https://files.example.com'],
['webdav_verified_origin', 'https://dav.files.example.com'],
['auth_signup_mode', SignupMode.CLOSED],
['captcha_enabled', 'true'],
['captcha_provider', 'hcaptcha'],
@@ -86,4 +87,16 @@ describe('getSiteConfig', () => {
expect(JSON.stringify(config)).not.toContain('oauth-secret')
expect(JSON.stringify(config)).not.toContain('client-id')
})
it('falls back to the path URL when the verified origin belongs to an old Public URL', async () => {
const config = await getSiteConfig(
makeDeps([
['site_public_origin', 'https://files.example.com'],
['webdav_verified_origin', 'https://dav.old.example.com'],
]),
'https://request.example.com/api/configz',
)
expect(config.services.webdav.url).toBe('https://files.example.com/dav/')
})
})
+6 -1
View File
@@ -31,6 +31,7 @@ const CONFIG_KEYS = [
SITE_SETTING_KEYS.captchaSiteKey,
SITE_SETTING_KEYS.captchaSecretKey,
SITE_SETTING_KEYS.captchaMinScore,
SITE_SETTING_KEYS.webdavVerifiedOrigin,
]
function brandingView(config: Awaited<ReturnType<typeof readBranding>>): SiteBranding {
@@ -84,6 +85,10 @@ export async function getSiteConfig(deps: ConfigzDeps, requestUrl: string): Prom
captcha: captcha ? { enabled: true, provider: captcha.provider, siteKey: captcha.siteKey } : { enabled: false },
providers,
},
services: { webdav: { url: effectiveWebDavUrl(requestUrl, publicUrl) } },
services: {
webdav: {
url: effectiveWebDavUrl(requestUrl, publicUrl, values.get(SITE_SETTING_KEYS.webdavVerifiedOrigin)),
},
},
}
}
+67
View File
@@ -11,6 +11,7 @@ import {
updateSiteIdentity,
updateSiteQuotas,
updateSiteRegistration,
verifySiteWebDav,
} from './settings'
vi.mock('./licensing', () => ({ loadBindingState: vi.fn(), resolveEffectiveSignupMode: vi.fn() }))
@@ -75,6 +76,13 @@ describe('site settings usecase', () => {
defaultTeamBytes: DEFAULT_ORG_QUOTA,
defaultMonthlyTrafficBytes: DEFAULT_ORG_TRAFFIC_QUOTA,
},
webdav: {
pathUrl: 'https://pan.example.com/dav/',
candidateUrl: 'https://dav.pan.example.com/',
status: 'unverified',
lastVerifiedAt: null,
error: null,
},
})
expect(JSON.stringify(settings)).not.toContain('secretKey')
})
@@ -93,6 +101,13 @@ describe('site settings usecase', () => {
expect.arrayContaining([{ key: 'site_public_origin', value: 'https://files.example.com' }]),
)
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'site_identity_update' }))
expect(setMany).toHaveBeenCalledWith(
expect.arrayContaining([
{ key: 'webdav_verified_origin', value: '' },
{ key: 'webdav_verified_at', value: '' },
{ key: 'webdav_verification_error', value: '' },
]),
)
})
it('requires white-label only when name or description changes', async () => {
@@ -172,4 +187,56 @@ describe('site settings usecase', () => {
{ key: 'default_org_monthly_traffic_quota', value: '4096' },
])
})
it('verifies and records the derived WebDAV origin', async () => {
const { deps, values, record } = makeDeps([['site_public_origin', 'https://files.example.com']])
const fetcher = vi.fn(
async () =>
new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="ZPan WebDAV"' },
}),
) as typeof fetch
const result = await verifySiteWebDav(
deps,
actor,
'https://files.example.com/api/site/settings/webdav/verification',
fetcher,
)
expect(fetcher).toHaveBeenCalledWith(
'https://dav.files.example.com/',
expect.objectContaining({ method: 'OPTIONS', redirect: 'manual' }),
)
expect(result).toMatchObject({
candidateUrl: 'https://dav.files.example.com/',
status: 'ready',
error: null,
})
expect(result.lastVerifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(values.get('webdav_verified_origin')).toBe('https://dav.files.example.com')
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'site_webdav_verify' }))
})
it('stores a failed verification and keeps the path URL available', async () => {
const { deps, values } = makeDeps([['site_public_origin', 'https://files.example.com']])
const fetcher = vi.fn(async () => new Response('Not Found', { status: 404 })) as typeof fetch
const result = await verifySiteWebDav(
deps,
actor,
'https://files.example.com/api/site/settings/webdav/verification',
fetcher,
)
expect(result).toEqual({
pathUrl: 'https://files.example.com/dav/',
candidateUrl: 'https://dav.files.example.com/',
status: 'failed',
lastVerifiedAt: null,
error: 'WebDAV verification returned HTTP 404 without the expected authentication challenge.',
})
expect(values.get('webdav_verified_origin')).toBe('')
})
})
+83 -1
View File
@@ -19,6 +19,7 @@ import type {
SiteQuotaSettings,
SiteRegistrationSettings,
SiteSettings,
SiteWebDavSettings,
UpdateSiteCaptchaInput,
UpdateSiteIdentityInput,
UpdateSiteQuotasInput,
@@ -27,6 +28,7 @@ import type {
import { readCaptchaConfig } from '../../domain/captcha'
import { hasFeature } from '../../domain/licensing'
import { normalizePublicOrigin, SITE_PUBLIC_ORIGIN_KEY } from '../../domain/site-public-origin'
import { WEBDAV_AUTH_CHALLENGE, webDavPathUrl, webDavPublicUrl } from '../../domain/webdav-public-url'
import {
type ActivityRepo,
badRequest,
@@ -50,6 +52,9 @@ export const SITE_SETTING_KEYS = {
defaultOrgQuota: 'default_org_quota',
defaultTeamQuota: 'default_team_quota',
defaultMonthlyTrafficQuota: 'default_org_monthly_traffic_quota',
webdavVerifiedOrigin: 'webdav_verified_origin',
webdavVerifiedAt: 'webdav_verified_at',
webdavVerificationError: 'webdav_verification_error',
} as const
const ALL_SETTING_KEYS = Object.values(SITE_SETTING_KEYS)
@@ -123,6 +128,23 @@ function quotasFrom(values: Map<string, string>): SiteQuotaSettings {
}
}
function webdavFrom(values: Map<string, string>, requestUrl: string): SiteWebDavSettings {
const publicUrl = normalizePublicOrigin(values.get(SITE_SETTING_KEYS.publicOrigin)) ?? new URL(requestUrl).origin
const candidate = webDavPublicUrl(publicUrl)
const verifiedOrigin = normalizePublicOrigin(values.get(SITE_SETTING_KEYS.webdavVerifiedOrigin))
const error = values.get(SITE_SETTING_KEYS.webdavVerificationError)?.trim() || null
const ready = candidate !== null && candidate.origin === verifiedOrigin
const lastVerifiedAt = ready ? values.get(SITE_SETTING_KEYS.webdavVerifiedAt)?.trim() || null : null
return {
pathUrl: webDavPathUrl(requestUrl, publicUrl),
candidateUrl: candidate ? `${candidate.origin}/` : null,
status: ready ? 'ready' : error ? 'failed' : 'unverified',
lastVerifiedAt,
error: ready ? null : error,
}
}
async function registrationFrom(
deps: Pick<SiteSettingsDeps, 'licenseBinding'>,
values: Map<string, string>,
@@ -144,6 +166,7 @@ export async function getSiteSettings(
registration: await registrationFrom(deps, values),
captcha: captchaFrom(values),
quotas: quotasFrom(values),
webdav: webdavFrom(values, requestUrl),
}
}
@@ -170,7 +193,13 @@ export async function updateSiteIdentity(
const publicUrl = normalizePublicOrigin(input.publicUrl)
if (!publicUrl) throw badRequest('Public URL must be an HTTP or HTTPS origin')
const current = optionMap(await deps.systemOptions.getMany([SITE_SETTING_KEYS.name, SITE_SETTING_KEYS.description]))
const current = optionMap(
await deps.systemOptions.getMany([
SITE_SETTING_KEYS.name,
SITE_SETTING_KEYS.description,
SITE_SETTING_KEYS.publicOrigin,
]),
)
const identityChanged =
input.name !== (current.get(SITE_SETTING_KEYS.name) ?? DEFAULT_SITE_NAME) ||
input.description !== (current.get(SITE_SETTING_KEYS.description) ?? DEFAULT_SITE_DESCRIPTION)
@@ -183,16 +212,69 @@ export async function updateSiteIdentity(
}
}
const publicUrlChanged = normalizePublicOrigin(current.get(SITE_SETTING_KEYS.publicOrigin)) !== publicUrl
await deps.systemOptions.setMany([
{ key: SITE_SETTING_KEYS.name, value: input.name },
{ key: SITE_SETTING_KEYS.description, value: input.description },
{ key: SITE_SETTING_KEYS.publicOrigin, value: publicUrl },
...(publicUrlChanged
? [
{ key: SITE_SETTING_KEYS.webdavVerifiedOrigin, value: '' },
{ key: SITE_SETTING_KEYS.webdavVerifiedAt, value: '' },
{ key: SITE_SETTING_KEYS.webdavVerificationError, value: '' },
]
: []),
])
resetSitePublicOriginCache()
await recordUpdate(deps, actor, 'site_identity_update', ['name', 'description', 'publicUrl'])
return { ...input, publicUrl }
}
export async function verifySiteWebDav(
deps: Pick<SiteSettingsDeps, 'systemOptions' | 'activity'>,
actor: { userId: string; orgId: string },
requestUrl: string,
fetcher: typeof fetch,
): Promise<SiteWebDavSettings> {
const values = optionMap(await deps.systemOptions.getMany(ALL_SETTING_KEYS))
const candidate = webdavFrom(values, requestUrl).candidateUrl
let error: string | null = null
await deps.systemOptions.setMany([
{ key: SITE_SETTING_KEYS.webdavVerifiedOrigin, value: '' },
{ key: SITE_SETTING_KEYS.webdavVerifiedAt, value: '' },
{ key: SITE_SETTING_KEYS.webdavVerificationError, value: '' },
])
if (!candidate) {
error = 'The Public URL must use a hostname before a WebDAV domain can be verified.'
} else {
try {
const response = await fetcher(candidate, {
method: 'OPTIONS',
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
})
if (response.status !== 401 || response.headers.get('WWW-Authenticate') !== WEBDAV_AUTH_CHALLENGE) {
error = `WebDAV verification returned HTTP ${response.status} without the expected authentication challenge.`
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'WebDAV verification request failed.'
}
}
const verifiedAt = error ? '' : new Date().toISOString()
await deps.systemOptions.setMany([
{ key: SITE_SETTING_KEYS.webdavVerifiedOrigin, value: error || !candidate ? '' : new URL(candidate).origin },
{ key: SITE_SETTING_KEYS.webdavVerifiedAt, value: verifiedAt },
{ key: SITE_SETTING_KEYS.webdavVerificationError, value: error ?? '' },
])
await recordUpdate(deps, actor, 'site_webdav_verify', ['status'])
const updatedValues = optionMap(await deps.systemOptions.getMany(ALL_SETTING_KEYS))
return webdavFrom(updatedValues, requestUrl)
}
export async function updateSiteRegistration(
deps: SiteSettingsDeps,
actor: { userId: string; orgId: string },
+3
View File
@@ -146,6 +146,7 @@ export type {
SiteQuotaSettings,
SiteRegistrationSettings,
SiteSettings,
SiteWebDavSettings,
UpdateSiteCaptchaInput,
UpdateSiteIdentityInput,
UpdateSiteQuotasInput,
@@ -163,10 +164,12 @@ export {
siteQuotaSettingsSchema,
siteRegistrationSettingsSchema,
siteSettingsSchema,
siteWebDavSettingsSchema,
updateSiteCaptchaSchema,
updateSiteIdentitySchema,
updateSiteQuotasSchema,
updateSiteRegistrationSchema,
webDavVerificationStatusSchema,
} from './site-config'
export type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from './storage'
export { createStorageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from './storage'
+16
View File
@@ -109,12 +109,27 @@ export const siteQuotaSettingsSchema = z
})
.openapi('SiteQuotaSettings')
export const webDavVerificationStatusSchema = z
.enum(['unverified', 'ready', 'failed'])
.openapi('WebDavVerificationStatus')
export const siteWebDavSettingsSchema = z
.object({
pathUrl: z.url(),
candidateUrl: z.url().nullable(),
status: webDavVerificationStatusSchema,
lastVerifiedAt: z.iso.datetime().nullable(),
error: z.string().nullable(),
})
.openapi('SiteWebDavSettings')
export const siteSettingsSchema = z
.object({
identity: siteIdentitySettingsSchema,
registration: siteRegistrationSettingsSchema,
captcha: siteCaptchaSettingsSchema,
quotas: siteQuotaSettingsSchema,
webdav: siteWebDavSettingsSchema,
})
.openapi('SiteSettings')
@@ -138,6 +153,7 @@ export type SiteIdentitySettings = z.infer<typeof siteIdentitySettingsSchema>
export type SiteRegistrationSettings = z.infer<typeof siteRegistrationSettingsSchema>
export type SiteCaptchaSettings = z.infer<typeof siteCaptchaSettingsSchema>
export type SiteQuotaSettings = z.infer<typeof siteQuotaSettingsSchema>
export type SiteWebDavSettings = z.infer<typeof siteWebDavSettingsSchema>
export type UpdateSiteIdentityInput = z.infer<typeof updateSiteIdentitySchema>
export type UpdateSiteRegistrationInput = z.infer<typeof updateSiteRegistrationSchema>
export type UpdateSiteCaptchaInput = z.infer<typeof updateSiteCaptchaSchema>
+4 -4
View File
@@ -15,10 +15,10 @@ Feature: Site configuration
Then access is denied and the generic Options API is unavailable
@system/webdav-url @api
Scenario: Configz derives the WebDAV URL from Public URL
Given an admin-configured Public URL
When configz is requested
Then the WebDAV URL uses the derived dav subdomain
Scenario: Configz publishes only a verified WebDAV domain
Given an admin-configured Public URL with an unverified derived WebDAV domain
When configz is requested before and after an admin verifies that domain
Then the WebDAV URL uses the path fallback first and the derived dav subdomain after verification
@system/captcha-secret-private @api
Scenario: Captcha is updated as a group without exposing its secret
+3
View File
@@ -12,6 +12,9 @@ const ADMIN_SETTINGS_KEYS = [
'admin.settings.siteDescription',
'admin.settings.registrationTitle',
'admin.settings.registrationLabel',
'admin.settings.webdavTitle',
'admin.settings.webdavVerify',
'admin.settings.webdavStatus.ready',
'admin.settings.branding.themeModePlaceholder',
'admin.settings.branding.colorPlaceholder',
'admin.settings.captchaProviderPlaceholder',
+19
View File
@@ -709,6 +709,25 @@
"admin.settings.siteDescriptionHint": "One or two sentences are enough. This copy should explain the purpose of the instance.",
"admin.settings.sitePublicOriginPlaceholder": "https://files.example.com",
"admin.settings.sitePublicOriginHint": "The externally reachable address used to build absolute links in share pages and emails. Leave blank to auto-detect it from incoming requests.",
"admin.settings.webdavSection": "WebDAV",
"admin.settings.webdavTitle": "WebDAV domain",
"admin.settings.webdavDescription": "Use the derived dav. hostname after its DNS and proxy routing are verified.",
"admin.settings.webdavDetails": "Details",
"admin.settings.webdavDrawerDescription": "Check whether the derived WebDAV hostname reaches this ZPan instance correctly.",
"admin.settings.webdavPathUrl": "Path URL",
"admin.settings.webdavPathUrlHint": "This URL remains available until the dedicated hostname is verified.",
"admin.settings.webdavCandidateUrl": "Derived domain",
"admin.settings.webdavCandidateUrlHint": "Configure this hostname in DNS and route its root path to ZPan's /dav path.",
"admin.settings.webdavVerificationStatus": "Verification status",
"admin.settings.webdavStatus.unverified": "Not verified",
"admin.settings.webdavStatus.ready": "Ready",
"admin.settings.webdavStatus.failed": "Verification failed",
"admin.settings.webdavLastVerified": "Last verified: {{value}}",
"admin.settings.webdavVerificationHint": "Verification sends an unauthenticated OPTIONS request and expects ZPan's WebDAV authentication challenge. It does not configure DNS or your reverse proxy.",
"admin.settings.webdavVerify": "Verify domain",
"admin.settings.webdavVerifying": "Verifying…",
"admin.settings.webdavVerified": "WebDAV domain verified",
"admin.settings.webdavVerificationFailed": "WebDAV domain verification failed",
"admin.settings.quotaDescription": "Define initial workspace storage and whether users can add storage through plans.",
"admin.settings.storageProTooltip": "Storage controls are available in ZPan Pro.",
"admin.settings.cloudStoreEnabled": "Storage Plans",
+19
View File
@@ -709,6 +709,25 @@
"admin.settings.siteDescriptionHint": "一两句话就够,重点说明这个实例是做什么的。",
"admin.settings.sitePublicOriginPlaceholder": "https://files.example.com",
"admin.settings.sitePublicOriginHint": "对外可访问的地址,用于在分享页和邮件中生成绝对链接。留空则根据访问请求自动识别。",
"admin.settings.webdavSection": "WebDAV",
"admin.settings.webdavTitle": "WebDAV 域名",
"admin.settings.webdavDescription": "确认 DNS 和代理路由可用后,使用自动推导的 dav. 子域名。",
"admin.settings.webdavDetails": "详情",
"admin.settings.webdavDrawerDescription": "检查自动推导的 WebDAV 域名是否已经正确访问到当前 ZPan 实例。",
"admin.settings.webdavPathUrl": "路径地址",
"admin.settings.webdavPathUrlHint": "在独立域名验证成功之前,这个地址会继续保持可用。",
"admin.settings.webdavCandidateUrl": "推导域名",
"admin.settings.webdavCandidateUrlHint": "请为这个域名配置 DNS,并通过代理把根路径转发到 ZPan 的 /dav 路径。",
"admin.settings.webdavVerificationStatus": "验证状态",
"admin.settings.webdavStatus.unverified": "未验证",
"admin.settings.webdavStatus.ready": "可用",
"admin.settings.webdavStatus.failed": "验证失败",
"admin.settings.webdavLastVerified": "上次验证:{{value}}",
"admin.settings.webdavVerificationHint": "验证会发送一个未认证的 OPTIONS 请求,并检查 ZPan 的 WebDAV 认证响应;它不会替你配置 DNS 或反向代理。",
"admin.settings.webdavVerify": "验证域名",
"admin.settings.webdavVerifying": "验证中…",
"admin.settings.webdavVerified": "WebDAV 域名验证成功",
"admin.settings.webdavVerificationFailed": "WebDAV 域名验证失败",
"admin.settings.quotaDescription": "配置工作空间的初始存储空间,以及是否允许用户通过套餐增加存储空间。",
"admin.settings.storageProTooltip": "Storage 控制项仅在 ZPan Pro 中可用。",
"admin.settings.cloudStoreEnabled": "存储套餐",
+24 -1
View File
@@ -147,6 +147,7 @@ import {
uploadToS3,
upsertAuthProvider,
verifySharePassword,
verifySiteWebDav,
} from './api'
function makeResponse(body: unknown, ok = true, status = 200): Response {
@@ -1911,7 +1912,7 @@ describe('api', () => {
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
},
auth: { signupMode: 'invite_only', captcha: { enabled: false }, providers: [] },
services: { webdav: { url: 'https://dav.pan.example.com/' } },
services: { webdav: { url: 'https://pan.example.com/dav/' } },
} as const
const settings = {
identity: config.site,
@@ -1924,6 +1925,13 @@ describe('api', () => {
minScore: null,
},
quotas: { defaultOrgBytes: 1024, defaultTeamBytes: 1024, defaultMonthlyTrafficBytes: 0 },
webdav: {
pathUrl: 'https://pan.example.com/dav/',
candidateUrl: 'https://dav.pan.example.com/',
status: 'unverified',
lastVerifiedAt: null,
error: null,
},
} as const
it('gets the public configz document', async () => {
@@ -2019,6 +2027,21 @@ describe('api', () => {
updateSiteQuotas({ defaultOrgBytes: 0, defaultTeamBytes: 0, defaultMonthlyTrafficBytes: 0 }),
).rejects.toThrow('invalid')
})
it('verifies the derived WebDAV domain', async () => {
const result = { ...settings.webdav, status: 'ready', lastVerifiedAt: '2026-07-20T12:00:00.000Z' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(result))
await expect(verifySiteWebDav()).resolves.toEqual(result)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/webdav/verification')
expect(init.method).toBe('POST')
})
it('throws when WebDAV verification fails at the API boundary', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
await expect(verifySiteWebDav()).rejects.toMatchObject({ name: 'ApiError', status: 401 })
})
})
describe('getSession', () => {
+4
View File
@@ -131,6 +131,10 @@ export function updateSiteQuotas(input: UpdateSiteQuotasInput) {
return unwrap<SiteSettings['quotas']>(siteSettingsApi.quotas.$put({ json: input }))
}
export function verifySiteWebDav() {
return unwrap<SiteSettings['webdav']>(siteSettingsApi.webdav.verification.$post())
}
export type UserQuota = Pick<
OrgQuota,
| 'orgId'
@@ -3,7 +3,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { updateSiteCaptcha, updateSiteIdentity, updateSiteQuotas, updateSiteRegistration } from '@/lib/api'
import {
updateSiteCaptcha,
updateSiteIdentity,
updateSiteQuotas,
updateSiteRegistration,
verifySiteWebDav,
} from '@/lib/api'
import { SettingsPage } from './index'
const state = vi.hoisted(() => ({
@@ -23,6 +29,13 @@ const state = vi.hoisted(() => ({
defaultTeamBytes: 1073741824,
defaultMonthlyTrafficBytes: 0,
},
webdav: {
pathUrl: 'https://zpan.example.com/dav/',
candidateUrl: 'https://dav.zpan.example.com/',
status: 'unverified',
lastVerifiedAt: null,
error: null,
},
},
}))
@@ -46,6 +59,7 @@ vi.mock('@/lib/api', () => ({
updateSiteRegistration: vi.fn(),
updateSiteCaptcha: vi.fn(),
updateSiteQuotas: vi.fn(),
verifySiteWebDav: vi.fn(),
}))
function renderSettingsPage() {
@@ -59,13 +73,13 @@ function renderSettingsPage() {
)
}
function openSection(view: ReturnType<typeof renderSettingsPage>, title: string) {
function openSection(view: ReturnType<typeof renderSettingsPage>, title: string, action = 'common.edit') {
const section = view
.getAllByText(title)
.map((element) => element.closest('[data-settings-row]'))
.find(Boolean)
if (!section) throw new Error(`${title} section not found`)
fireEvent.click(within(section as HTMLElement).getByRole('button', { name: 'common.edit' }))
fireEvent.click(within(section as HTMLElement).getByRole('button', { name: action }))
}
beforeEach(() => {
@@ -176,4 +190,23 @@ describe('SettingsPage', () => {
it('shows email configuration on the settings page', () => {
expect(renderSettingsPage().getByText('email-config')).toBeTruthy()
})
it('verifies the derived WebDAV domain from its settings drawer', async () => {
vi.mocked(verifySiteWebDav).mockResolvedValueOnce({
...state.settings.webdav,
status: 'ready',
lastVerifiedAt: '2026-07-20T12:00:00.000Z',
})
const view = renderSettingsPage()
openSection(view, 'admin.settings.webdavTitle', 'admin.settings.webdavDetails')
expect(view.getByLabelText('admin.settings.webdavCandidateUrl')).toHaveProperty(
'value',
'https://dav.zpan.example.com/',
)
fireEvent.click(view.getByRole('button', { name: 'admin.settings.webdavVerify' }))
await waitFor(() => expect(verifySiteWebDav).toHaveBeenCalledOnce())
expect(toast.success).toHaveBeenCalledWith('admin.settings.webdavVerified')
})
})
@@ -7,9 +7,10 @@ import {
DEFAULT_SITE_NAME,
SignupMode,
} from '@shared/constants'
import type { SiteSettings } from '@shared/schemas'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Database, Globe2, ShieldCheck, UserPlus } from 'lucide-react'
import { Database, Globe2, Network, ShieldCheck, UserPlus } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
@@ -31,7 +32,13 @@ import { Textarea } from '@/components/ui/textarea'
import { siteConfigQueryKey } from '@/hooks/use-site-config'
import { siteSettingsQueryKey, useSiteSettings } from '@/hooks/use-site-settings'
import { useEntitlement } from '@/hooks/useEntitlement'
import { updateSiteCaptcha, updateSiteIdentity, updateSiteQuotas, updateSiteRegistration } from '@/lib/api'
import {
updateSiteCaptcha,
updateSiteIdentity,
updateSiteQuotas,
updateSiteRegistration,
verifySiteWebDav,
} from '@/lib/api'
export const Route = createFileRoute('/_authenticated/admin/settings/')({
component: SettingsPage,
@@ -73,7 +80,7 @@ const settingsSchema = z.object({
})
type SettingsFormValues = z.infer<typeof settingsSchema>
type SettingsDrawer = 'identity' | 'registration' | 'captcha' | 'storage' | null
type SettingsDrawer = 'identity' | 'registration' | 'captcha' | 'webdav' | 'storage' | null
type FieldControlProps = {
id?: string
'aria-invalid'?: boolean
@@ -171,6 +178,7 @@ export function SettingsPage() {
const captchaSiteKey = settings?.captcha.siteKey ?? ''
const captchaSecretConfigured = settings?.captcha.secretConfigured ?? false
const captchaMinScore = settings?.captcha.minScore === null ? '' : String(settings?.captcha.minScore ?? '')
const webdav = settings?.webdav
const { hasFeature } = useEntitlement()
const hasWhiteLabel = hasFeature('white_label')
const hasOpenRegistration = hasFeature('open_registration')
@@ -339,6 +347,22 @@ export function SettingsPage() {
},
})
const webdavVerificationMutation = useMutation({
mutationFn: verifySiteWebDav,
onSuccess: (result) => {
queryClient.setQueryData<SiteSettings>(siteSettingsQueryKey, (current) =>
current ? { ...current, webdav: result } : current,
)
queryClient.invalidateQueries({ queryKey: siteSettingsQueryKey })
queryClient.invalidateQueries({ queryKey: siteConfigQueryKey })
if (result.status === 'ready') toast.success(t('admin.settings.webdavVerified'))
else toast.error(result.error ?? t('admin.settings.webdavVerificationFailed'))
},
onError: (err) => {
toast.error(err.message)
},
})
const quotaUnit = form.watch('quotaUnit')
const teamQuotaUnit = form.watch('teamQuotaUnit')
const registrationsEnabled = form.watch('registrationsEnabled')
@@ -420,6 +444,26 @@ export function SettingsPage() {
/>
</SettingsSection>
<SettingsSection title={t('admin.settings.webdavSection')}>
<SettingsItemCard
icon={<Network className="size-4" />}
title={t('admin.settings.webdavTitle')}
description={t('admin.settings.webdavDescription')}
details={webdav?.status === 'ready' ? webdav.candidateUrl : webdav?.pathUrl}
status={
<Badge
variant={
webdav?.status === 'ready' ? 'default' : webdav?.status === 'failed' ? 'destructive' : 'secondary'
}
>
{t(`admin.settings.webdavStatus.${webdav?.status ?? 'unverified'}`)}
</Badge>
}
editLabel={t('admin.settings.webdavDetails')}
onEdit={() => setSettingsDrawer('webdav')}
/>
</SettingsSection>
<SettingsSection title={t('admin.settings.storageSection')}>
<SettingsItemCard
icon={<Database className="size-4" />}
@@ -507,6 +551,64 @@ export function SettingsPage() {
</AdminFormField>
</AdminFormDrawer>
<AdminFormDrawer
open={settingsDrawer === 'webdav'}
onOpenChange={(open) => !open && closeSettingsDrawer()}
title={t('admin.settings.webdavTitle')}
description={t('admin.settings.webdavDrawerDescription')}
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<>
<Button type="button" variant="outline" onClick={() => closeSettingsDrawer()}>
{t('common.close')}
</Button>
<Button
type="button"
disabled={!webdav?.candidateUrl || webdavVerificationMutation.isPending}
onClick={() => webdavVerificationMutation.mutate()}
>
{webdavVerificationMutation.isPending
? t('admin.settings.webdavVerifying')
: t('admin.settings.webdavVerify')}
</Button>
</>
}
>
<AdminFormField
id="webdavPathUrl"
label={t('admin.settings.webdavPathUrl')}
help={t('admin.settings.webdavPathUrlHint')}
>
<Input id="webdavPathUrl" readOnly value={webdav?.pathUrl ?? ''} />
</AdminFormField>
<AdminFormField
id="webdavCandidateUrl"
label={t('admin.settings.webdavCandidateUrl')}
help={t('admin.settings.webdavCandidateUrlHint')}
>
<Input id="webdavCandidateUrl" readOnly value={webdav?.candidateUrl ?? ''} />
</AdminFormField>
<div className="rounded-md border bg-muted/40 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{t('admin.settings.webdavVerificationStatus')}</span>
<Badge
variant={
webdav?.status === 'ready' ? 'default' : webdav?.status === 'failed' ? 'destructive' : 'secondary'
}
>
{t(`admin.settings.webdavStatus.${webdav?.status ?? 'unverified'}`)}
</Badge>
</div>
{webdav?.lastVerifiedAt && (
<p className="mt-2 text-muted-foreground">
{t('admin.settings.webdavLastVerified', { value: new Date(webdav.lastVerifiedAt).toLocaleString() })}
</p>
)}
{webdav?.error && <p className="mt-2 text-destructive">{webdav.error}</p>}
</div>
<p className="text-muted-foreground text-xs leading-5">{t('admin.settings.webdavVerificationHint')}</p>
</AdminFormDrawer>
<AdminFormDrawer
open={settingsDrawer === 'registration'}
onOpenChange={(open) => !open && closeSettingsDrawer()}