189 lines
5.7 KiB
JavaScript
189 lines
5.7 KiB
JavaScript
'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 };
|