mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(webdav): verify derived domains before publishing
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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] ?? '')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user