'use strict'; /** * Filestash client. Holds a single proof cookie obtained by POSTing to the * share proof endpoint, refreshes it on auth-style failures, and exposes * directory listing plus a streaming download for the media proxy. */ class FilestashClient { constructor({ baseUrl, shareId, fetchImpl = globalThis.fetch }) { if (!baseUrl || !shareId) { throw new Error('FilestashClient requires baseUrl and shareId'); } this.baseUrl = baseUrl.replace(/\/+$/, ''); this.shareId = shareId; this.fetch = fetchImpl; this.proofCookie = null; this.basePath = '/'; this.proofPromise = null; } _commonHeaders(extra = {}) { const headers = { 'X-Requested-With': 'XmlHttpRequest', 'Accept': 'application/json', ...extra, }; if (this.proofCookie) { headers['Cookie'] = this.proofCookie; } return headers; } /** * Parse Set-Cookie header(s) and keep cookies relevant to this share. * Returns a single "name=value; name2=value2" string suitable for Cookie header. */ _captureCookies(response) { // Node fetch exposes raw Set-Cookie via headers.getSetCookie() (Node 20+). let raws = []; if (typeof response.headers.getSetCookie === 'function') { raws = response.headers.getSetCookie(); } else { const single = response.headers.get('set-cookie'); if (single) raws = [single]; } if (!raws.length) return; const existing = new Map(); if (this.proofCookie) { for (const pair of this.proofCookie.split(/;\s*/)) { const eq = pair.indexOf('='); if (eq > 0) existing.set(pair.slice(0, eq), pair.slice(eq + 1)); } } for (const raw of raws) { const firstSemi = raw.indexOf(';'); const pair = firstSemi === -1 ? raw : raw.slice(0, firstSemi); const eq = pair.indexOf('='); if (eq <= 0) continue; const name = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim(); if (!name) continue; existing.set(name, value); } this.proofCookie = [...existing.entries()] .map(([k, v]) => `${k}=${v}`) .join('; '); } async _doProof() { const url = `${this.baseUrl}/api/share/${encodeURIComponent(this.shareId)}/proof`; const res = await this.fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XmlHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({}), }); if (!res.ok) { const text = await safeText(res); throw new Error(`Filestash proof failed: ${res.status} ${text}`); } this._captureCookies(res); const body = await res.json().catch(() => ({})); const result = body && body.result; if (result && typeof result.path === 'string' && result.path.length) { this.basePath = result.path; } return { basePath: this.basePath }; } async ensureProof() { if (this.proofCookie) return; if (!this.proofPromise) { this.proofPromise = this._doProof().finally(() => { this.proofPromise = null; }); } await this.proofPromise; } _resetProof() { this.proofCookie = null; } async _request(buildRequest) { await this.ensureProof(); let res = await buildRequest(); if (res.status === 401 || res.status === 403) { this._resetProof(); await this.ensureProof(); res = await buildRequest(); } return res; } /** * Lists a directory. `relPath` is share-relative — "/" is the share root, * a sub-path like "/Ambiences/" lists that folder. The absolute filesystem * path reported by /proof (basePath) is metadata only and is NOT passed * to ls/cat — the Filestash share API rewrites paths internally. * Returns array of entries { name, type: 'file'|'directory', size, time }. */ async listDir(relPath = '/') { const sharePath = normalizeSharePath(relPath); const url = new URL(`${this.baseUrl}/api/files/ls`); url.searchParams.set('path', sharePath); url.searchParams.set('share', this.shareId); const res = await this._request(() => this.fetch(url.toString(), { method: 'GET', headers: this._commonHeaders() }) ); if (!res.ok) { const text = await safeText(res); throw new Error(`Filestash ls failed: ${res.status} ${text} (${sharePath})`); } const body = await res.json(); const results = (body && body.results) || []; return results.map((entry) => ({ name: String(entry.name || ''), type: entry.type === 'directory' ? 'directory' : 'file', size: typeof entry.size === 'number' ? entry.size : null, time: typeof entry.time === 'number' ? entry.time : null, })); } /** * Streams a single file by its share-relative path (must start with "/"). * Returns the underlying fetch Response so the caller can pipe headers + body. */ async streamFile(relPath, { range } = {}) { const sharePath = normalizeSharePath(relPath); const url = new URL(`${this.baseUrl}/api/files/cat`); url.searchParams.set('path', sharePath); url.searchParams.set('share', this.shareId); const headers = this._commonHeaders(); if (range) headers['Range'] = range; const res = await this._request(() => this.fetch(url.toString(), { method: 'GET', headers }) ); return res; } } function normalizeSharePath(p) { if (!p || p === '/') return '/'; let s = String(p); if (!s.startsWith('/')) s = `/${s}`; // Collapse any accidental double slashes. s = s.replace(/\/{2,}/g, '/'); return s; } async function safeText(res) { try { return (await res.text()).slice(0, 200); } catch { return ''; } } module.exports = { FilestashClient };