Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f034d9e95 | ||
|
|
9e6c3859c0 |
@@ -121,6 +121,7 @@ RUN chmod +x /usr/local/bin/meta-core
|
|||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
COPY docker/wsgidav.yaml /app/docker/
|
COPY docker/wsgidav.yaml /app/docker/
|
||||||
|
COPY docker/webdav_token_controller.py /app/docker/
|
||||||
COPY docker/welcome.html /app/welcome/
|
COPY docker/welcome.html /app/welcome/
|
||||||
COPY docker/start-fuse-driver.sh /app/docker/
|
COPY docker/start-fuse-driver.sh /app/docker/
|
||||||
RUN chmod 644 /app/welcome/welcome.html && \
|
RUN chmod 644 /app/welcome/welcome.html && \
|
||||||
|
|||||||
@@ -72,3 +72,6 @@ autorestart=true
|
|||||||
stdout_logfile=/var/log/supervisor/wsgidav.log
|
stdout_logfile=/var/log/supervisor/wsgidav.log
|
||||||
stderr_logfile=/var/log/supervisor/wsgidav_error.log
|
stderr_logfile=/var/log/supervisor/wsgidav_error.log
|
||||||
startsecs=5
|
startsecs=5
|
||||||
|
environment=
|
||||||
|
PYTHONPATH="/app/docker",
|
||||||
|
TOKEN_STORE_PATH="/meta-fuse/config/webdav-tokens.json"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
WsgiDAV custom DomainController for meta-fuse WebDAV access tokens.
|
||||||
|
|
||||||
|
Reads `${TOKEN_STORE_PATH}` (a JSON file written by meta-fuse's TokenStore.ts)
|
||||||
|
fresh on every basic-auth request — no caching, so token revocation from the
|
||||||
|
dashboard takes effect on the next request.
|
||||||
|
|
||||||
|
Username is ignored: the WebDAV password IS the token. We accept any
|
||||||
|
non-empty username so generic clients (Finder, Plex, davfs2) that demand both
|
||||||
|
fields keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wsgidav.dc.base_dc import BaseDomainController
|
||||||
|
|
||||||
|
# Match the prefix produced by TokenStore.ts.
|
||||||
|
TOKEN_PREFIX = "mfwd_"
|
||||||
|
TOKEN_STORE_DEFAULT = "/meta-fuse/config/webdav-tokens.json"
|
||||||
|
|
||||||
|
|
||||||
|
class WebdavTokenController(BaseDomainController):
|
||||||
|
"""
|
||||||
|
Auth provider backed by meta-fuse's per-device token store.
|
||||||
|
|
||||||
|
WsgiDAV calls `basic_auth_user(realm, user_name, password, environ)` for
|
||||||
|
every request that requires auth. We hash the candidate password with
|
||||||
|
sha256 and check it against the persisted records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wsgidav_app: Any, config: dict) -> None:
|
||||||
|
super().__init__(wsgidav_app, config)
|
||||||
|
self._store_path = os.environ.get("TOKEN_STORE_PATH", TOKEN_STORE_DEFAULT)
|
||||||
|
|
||||||
|
# --- BaseDomainController contract --------------------------------------
|
||||||
|
|
||||||
|
def get_domain_realm(self, path_info: str, environ: dict) -> str:
|
||||||
|
# Single realm — all paths require the same token.
|
||||||
|
return "metafuse-webdav"
|
||||||
|
|
||||||
|
def require_authentication(self, realm: str, environ: dict) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_http_digest_auth(self) -> bool:
|
||||||
|
# Tokens are not stored in plaintext, so we cannot answer digest
|
||||||
|
# challenges — clients must use basic auth (over TLS).
|
||||||
|
return False
|
||||||
|
|
||||||
|
def basic_auth_user(
|
||||||
|
self, realm: str, user_name: str, password: str, environ: dict
|
||||||
|
) -> bool:
|
||||||
|
if not password or not password.startswith(TOKEN_PREFIX):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self._store_path, "r", encoding="utf-8") as fh:
|
||||||
|
store = json.load(fh)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
# Treat unreadable store as "no valid tokens" — fail closed.
|
||||||
|
return False
|
||||||
|
|
||||||
|
candidate = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
for token in store.get("tokens", []):
|
||||||
|
if token.get("hash") == candidate:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+8
-16
@@ -3,17 +3,14 @@
|
|||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
# Simple user authentication
|
# Authentication is delegated to a custom DomainController that validates the
|
||||||
simple_dc:
|
# basic-auth password against meta-fuse's per-device token store. Any
|
||||||
user_mapping:
|
# non-empty username is accepted; the password IS the token (`mfwd_*`).
|
||||||
"/":
|
http_authenticator:
|
||||||
metamesh:
|
domain_controller: webdav_token_controller.WebdavTokenController
|
||||||
password: metamesh
|
accept_basic: true
|
||||||
roles: ["editor"]
|
accept_digest: false
|
||||||
"/webdav":
|
default_to_digest: false
|
||||||
metamesh:
|
|
||||||
password: metamesh
|
|
||||||
roles: ["editor"]
|
|
||||||
|
|
||||||
# Mount the virtual filesystem
|
# Mount the virtual filesystem
|
||||||
provider_mapping:
|
provider_mapping:
|
||||||
@@ -31,11 +28,6 @@ server: cheroot
|
|||||||
server_args:
|
server_args:
|
||||||
numthreads: 10
|
numthreads: 10
|
||||||
|
|
||||||
# Authentication settings
|
|
||||||
accept_basic: true
|
|
||||||
accept_digest: false
|
|
||||||
default_to_digest: false
|
|
||||||
|
|
||||||
# HTTP configuration
|
# HTTP configuration
|
||||||
add_header_MS_Author_Via: false
|
add_header_MS_Author_Via: false
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types
|
|||||||
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
|
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
|
||||||
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
|
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
|
||||||
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
|
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
|
||||||
|
import { TokenStore } from '../webdav/TokenStore.js';
|
||||||
|
|
||||||
const logger = new Logger({ name: 'APIServer' });
|
const logger = new Logger({ name: 'APIServer' });
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export class APIServer {
|
|||||||
private vfs: VirtualFileSystem;
|
private vfs: VirtualFileSystem;
|
||||||
private kvManager: KVManager | null = null;
|
private kvManager: KVManager | null = null;
|
||||||
private config: Required<APIServerConfig>;
|
private config: Required<APIServerConfig>;
|
||||||
|
private tokenStore: TokenStore;
|
||||||
|
|
||||||
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
|
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
|
||||||
this.vfs = vfs;
|
this.vfs = vfs;
|
||||||
@@ -38,6 +40,10 @@ export class APIServer {
|
|||||||
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
|
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
|
||||||
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
|
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
|
||||||
};
|
};
|
||||||
|
// CONFIG_DIR is the same dir ConfigStorage uses for renaming rules.
|
||||||
|
this.tokenStore = new TokenStore({
|
||||||
|
configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config',
|
||||||
|
});
|
||||||
|
|
||||||
this.app = Fastify({
|
this.app = Fastify({
|
||||||
logger: false,
|
logger: false,
|
||||||
@@ -88,6 +94,43 @@ export class APIServer {
|
|||||||
// Service discovery — proxies to meta-core's /api/services, resolving the
|
// Service discovery — proxies to meta-core's /api/services, resolving the
|
||||||
// current leader's URL via LeaderClient (no hardcoded hostname).
|
// current leader's URL via LeaderClient (no hardcoded hostname).
|
||||||
this.app.get('/api/services', this.handleServices.bind(this));
|
this.app.get('/api/services', this.handleServices.bind(this));
|
||||||
|
|
||||||
|
// WebDAV access tokens (per-device basic-auth passwords for /webdav).
|
||||||
|
this.app.get('/api/webdav-tokens', this.handleListTokens.bind(this));
|
||||||
|
this.app.post('/api/webdav-tokens', this.handleCreateToken.bind(this));
|
||||||
|
this.app.delete('/api/webdav-tokens/:id', this.handleRevokeToken.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleListTokens(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
reply.send({ tokens: this.tokenStore.list() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreateToken(
|
||||||
|
req: FastifyRequest<{ Body: { label?: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
): Promise<void> {
|
||||||
|
const label = (req.body?.label ?? '').toString();
|
||||||
|
if (label.length > 200) {
|
||||||
|
reply.status(400).send({ error: 'label too long (max 200 chars)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { plaintext, view } = this.tokenStore.create(label);
|
||||||
|
// The plaintext is included exactly once in this response — the dashboard
|
||||||
|
// surfaces it to the user and then discards it. Subsequent GETs do not
|
||||||
|
// include it.
|
||||||
|
reply.status(201).send({ ...view, token: plaintext });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRevokeToken(
|
||||||
|
req: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
): Promise<void> {
|
||||||
|
const ok = this.tokenStore.revoke(req.params.id);
|
||||||
|
if (!ok) {
|
||||||
|
reply.status(404).send({ error: 'token not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* TokenStore — Per-device WebDAV access tokens.
|
||||||
|
*
|
||||||
|
* Tokens are minted from the OIDC-protected dashboard and used as the
|
||||||
|
* basic-auth password by WebDAV clients (Plex, Finder, etc.). The plaintext
|
||||||
|
* is shown to the user once at creation time; only sha256 hashes are
|
||||||
|
* persisted. The matching WsgiDAV DomainController
|
||||||
|
* (`docker/webdav_token_controller.py`) reads the same JSON file fresh on
|
||||||
|
* every auth request, so revocation is immediate.
|
||||||
|
*
|
||||||
|
* Tokens identify a *device*, not an OIDC user — multi-user support requires
|
||||||
|
* hash-lock to forward an X-Forwarded-User header (not currently the case),
|
||||||
|
* at which point a `userId` field can be added to the record.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { Logger } from 'tslog';
|
||||||
|
|
||||||
|
const logger = new Logger({ name: 'TokenStore' });
|
||||||
|
|
||||||
|
/** Length of the random portion (in hex chars) of a generated token. */
|
||||||
|
const TOKEN_RANDOM_HEX_LEN = 32; // 16 bytes => 128 bits of entropy
|
||||||
|
/** Public prefix marker so support can grep logs for stray tokens. */
|
||||||
|
const TOKEN_PREFIX = 'mfwd_';
|
||||||
|
/** Number of hex chars (after the prefix) we surface in the dashboard list. */
|
||||||
|
const VISIBLE_PREFIX_LEN = 4;
|
||||||
|
|
||||||
|
export interface WebdavTokenRecord {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hash: string; // sha256(plaintext)
|
||||||
|
prefix: string; // first VISIBLE_PREFIX_LEN chars of the random portion
|
||||||
|
createdAt: string; // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public view shown in the dashboard list — no plaintext, no hash. */
|
||||||
|
export interface WebdavTokenView {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenFile {
|
||||||
|
tokens: WebdavTokenRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenStoreOptions {
|
||||||
|
configDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenStore {
|
||||||
|
private readonly file: string;
|
||||||
|
private readonly configDir: string;
|
||||||
|
|
||||||
|
constructor(opts: TokenStoreOptions) {
|
||||||
|
this.configDir = opts.configDir;
|
||||||
|
this.file = path.join(this.configDir, 'webdav-tokens.json');
|
||||||
|
this.ensureConfigDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureConfigDir(): void {
|
||||||
|
if (!fs.existsSync(this.configDir)) {
|
||||||
|
fs.mkdirSync(this.configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(): TokenFile {
|
||||||
|
if (!fs.existsSync(this.file)) return { tokens: [] };
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(this.file, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as TokenFile;
|
||||||
|
if (!Array.isArray(parsed.tokens)) return { tokens: [] };
|
||||||
|
return parsed;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Token store ${this.file} unreadable, treating as empty:`, err);
|
||||||
|
return { tokens: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic write — write to a sibling tmp file and rename. WsgiDAV's
|
||||||
|
* controller reads this file on every auth request, so a partial write
|
||||||
|
* would briefly fail authentication.
|
||||||
|
*/
|
||||||
|
private write(file: TokenFile): void {
|
||||||
|
const tmp = `${this.file}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(file, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(tmp, this.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public list — no plaintext, no hash. */
|
||||||
|
list(): WebdavTokenView[] {
|
||||||
|
return this.read().tokens.map(({ id, label, prefix, createdAt }) => ({
|
||||||
|
id, label, prefix, createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a new token. Returns the plaintext exactly once — the caller is
|
||||||
|
* responsible for surfacing it to the user and forgetting it.
|
||||||
|
*/
|
||||||
|
create(label: string): { plaintext: string; view: WebdavTokenView } {
|
||||||
|
const random = crypto.randomBytes(TOKEN_RANDOM_HEX_LEN / 2).toString('hex');
|
||||||
|
const plaintext = `${TOKEN_PREFIX}${random}`;
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
const record: WebdavTokenRecord = {
|
||||||
|
id,
|
||||||
|
label: label.trim() || 'Unnamed token',
|
||||||
|
hash: crypto.createHash('sha256').update(plaintext).digest('hex'),
|
||||||
|
prefix: random.slice(0, VISIBLE_PREFIX_LEN),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const file = this.read();
|
||||||
|
file.tokens.push(record);
|
||||||
|
this.write(file);
|
||||||
|
|
||||||
|
logger.info(`Minted WebDAV token id=${id} label=${JSON.stringify(record.label)}`);
|
||||||
|
return {
|
||||||
|
plaintext,
|
||||||
|
view: { id, label: record.label, prefix: record.prefix, createdAt: record.createdAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete by id. Returns true if a record was removed. */
|
||||||
|
revoke(id: string): boolean {
|
||||||
|
const file = this.read();
|
||||||
|
const idx = file.tokens.findIndex(t => t.id === id);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
file.tokens.splice(idx, 1);
|
||||||
|
this.write(file);
|
||||||
|
logger.info(`Revoked WebDAV token id=${id}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { RulesEditor } from './components/RulesEditor';
|
import { RulesEditor } from './components/RulesEditor';
|
||||||
import ServiceNav from './components/ServiceNav';
|
import ServiceNav from './components/ServiceNav';
|
||||||
|
import { WebdavTokens } from './components/WebdavTokens';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
@@ -39,6 +40,7 @@ function App() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [showRules, setShowRules] = useState(false);
|
const [showRules, setShowRules] = useState(false);
|
||||||
const [showSetup, setShowSetup] = useState(false);
|
const [showSetup, setShowSetup] = useState(false);
|
||||||
|
const [showTokens, setShowTokens] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -223,6 +225,17 @@ function App() {
|
|||||||
<RulesEditor onSave={fetchData} />
|
<RulesEditor onSave={fetchData} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="subsection">
|
||||||
|
<div
|
||||||
|
className="section-header-collapsible"
|
||||||
|
onClick={() => setShowTokens(!showTokens)}
|
||||||
|
>
|
||||||
|
<h3 className="subsection-title">WebDAV Access Tokens</h3>
|
||||||
|
<span className="collapse-icon">{showTokens ? '-' : '+'}</span>
|
||||||
|
</div>
|
||||||
|
{showTokens && <WebdavTokens />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface TokenView {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedToken extends TokenView {
|
||||||
|
token: string; // plaintext, surfaced only once
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebdavTokens() {
|
||||||
|
const [tokens, setTokens] = useState<TokenView[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [newLabel, setNewLabel] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [justCreated, setJustCreated] = useState<CreatedToken | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/webdav-tokens');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTokens(data.tokens ?? []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/webdav-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: newLabel }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const created: CreatedToken = await res.json();
|
||||||
|
setJustCreated(created);
|
||||||
|
setNewLabel('');
|
||||||
|
setCopied(false);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (id: string, label: string) => {
|
||||||
|
if (!confirm(`Revoke token "${label}"? Devices using it will lose WebDAV access immediately.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/webdav-tokens/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToken = async () => {
|
||||||
|
if (!justCreated) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(justCreated.token);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API may be unavailable on http; fall back silently.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="loading">Loading tokens…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="webdav-tokens">
|
||||||
|
<p className="info-note">
|
||||||
|
Generate one token per device and paste it as the basic-auth password
|
||||||
|
when you add the WebDAV mount in Plex / Finder / Windows Explorer / etc.
|
||||||
|
Any username works — the password is what authenticates. Revoke a
|
||||||
|
token to cut off that single device without affecting the others.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{justCreated && (
|
||||||
|
<div className="info-box" style={{ borderColor: '#10b981' }}>
|
||||||
|
<h4>New token — copy it now</h4>
|
||||||
|
<p className="info-note">
|
||||||
|
This is the only time the full token will be shown. After you close
|
||||||
|
this banner only the prefix will appear in the list.
|
||||||
|
</p>
|
||||||
|
<div className="command-box" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<code style={{ flex: 1, wordBreak: 'break-all' }}>{justCreated.token}</code>
|
||||||
|
<button onClick={copyToken} className="refresh-btn">
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setJustCreated(null)}
|
||||||
|
className="refresh-btn"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '1rem' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Generate token</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
placeholder="Label (e.g. 'Plex on living-room TV')"
|
||||||
|
maxLength={200}
|
||||||
|
style={{ flex: 1, padding: '0.5rem' }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && !creating) create(); }}
|
||||||
|
/>
|
||||||
|
<button onClick={create} disabled={creating} className="refresh-btn">
|
||||||
|
{creating ? 'Generating…' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-list" style={{ marginTop: '1rem' }}>
|
||||||
|
{tokens.length === 0 ? (
|
||||||
|
<div className="file-item">
|
||||||
|
<span className="file-name" style={{ color: '#64748b' }}>
|
||||||
|
No tokens yet — generate one to enable WebDAV access.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tokens.map((t) => (
|
||||||
|
<div key={t.id} className="file-item" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<span className="file-name"><strong>{t.label}</strong></span>
|
||||||
|
<div className="info-note">
|
||||||
|
<code>mfwd_{t.prefix}…</code> · created {formatDate(t.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => revoke(t.id, t.label)}
|
||||||
|
className="refresh-btn"
|
||||||
|
style={{ background: '#ef4444', color: 'white' }}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user