Add initial PsiTransfer Filelink provider scaffold

This commit is contained in:
Jordan Wages 2026-04-19 01:26:45 -05:00
commit 953cd501a7
16 changed files with 6283 additions and 1 deletions

30
src/background.js Normal file
View file

@ -0,0 +1,30 @@
import { AccountStore } from "./cloudfile/account-store.js";
import { CloudFileProvider } from "./cloudfile/provider.js";
import { error as logError, info } from "./util/log.js";
const accountStore = new AccountStore();
const provider = new CloudFileProvider({ accountStore });
async function bootstrap() {
browser.cloudFile.onAccountAdded.addListener(account => provider.onAccountAdded(account));
browser.cloudFile.onAccountDeleted.addListener(accountId => provider.onAccountDeleted(accountId));
browser.cloudFile.onFileUpload.addListener((account, fileInfo, tab, relatedFileInfo) => {
return provider.onFileUpload(account, fileInfo, tab, relatedFileInfo);
});
browser.cloudFile.onFileUploadAbort.addListener((account, fileId, tab) => {
return provider.onFileUploadAbort(account, fileId, tab);
});
browser.cloudFile.onFileDeleted.addListener((account, fileId, tab) => {
return provider.onFileDeleted(account, fileId, tab);
});
browser.cloudFile.onFileRename.addListener((account, fileId, newName, tab) => {
return provider.onFileRename(account, fileId, newName, tab);
});
await provider.initialize();
info("PsiTransfer Filelink scaffold initialized.");
}
bootstrap().catch(caughtError => {
logError("Failed to bootstrap background service worker", caughtError);
});

View file

@ -0,0 +1,70 @@
const ACCOUNT_PREFIX = "account:";
const FILE_PREFIX = "uploaded-file:";
export class AccountStore {
constructor(storageArea = globalThis.browser?.storage?.local) {
this.storageArea = storageArea;
}
async get(accountId) {
const key = this.#accountKey(accountId);
const result = await this.storageArea.get(key);
return result[key] ?? null;
}
async set(accountId, config) {
const key = this.#accountKey(accountId);
await this.storageArea.set({
[key]: {
...config,
accountId,
updatedAt: Date.now()
}
});
}
async remove(accountId) {
const keys = await this.storageArea.get(null);
const removeKeys = Object.keys(keys).filter(key => {
return key === this.#accountKey(accountId) || key.startsWith(`${this.#filePrefix(accountId)}:`);
});
if (removeKeys.length) {
await this.storageArea.remove(removeKeys);
}
}
async getUploadedFile(accountId, fileId) {
const key = this.#fileKey(accountId, fileId);
const result = await this.storageArea.get(key);
return result[key] ?? null;
}
async setUploadedFile(accountId, fileId, remoteRef) {
const key = this.#fileKey(accountId, fileId);
await this.storageArea.set({
[key]: {
...remoteRef,
accountId,
fileId,
updatedAt: Date.now()
}
});
}
async removeUploadedFile(accountId, fileId) {
await this.storageArea.remove(this.#fileKey(accountId, fileId));
}
#accountKey(accountId) {
return `${ACCOUNT_PREFIX}${accountId}`;
}
#filePrefix(accountId) {
return `${FILE_PREFIX}${accountId}`;
}
#fileKey(accountId, fileId) {
return `${this.#filePrefix(accountId)}:${fileId}`;
}
}

223
src/cloudfile/provider.js Normal file
View file

@ -0,0 +1,223 @@
import { PsiTransferClient } from "../psitransfer/client.js";
import { debug, error as logError, info, warn } from "../util/log.js";
/**
* @typedef {import("../psitransfer/types.js").PsiTransferAccountConfig} PsiTransferAccountConfig
*/
export class CloudFileProvider {
constructor({ accountStore, cloudFileApi = globalThis.browser?.cloudFile }) {
this.accountStore = accountStore;
this.cloudFileApi = cloudFileApi;
this.abortedUploads = new Set();
}
async initialize() {
const accounts = await this.cloudFileApi.getAllAccounts();
await Promise.all(accounts.map(account => this.ensureAccount(account)));
}
async ensureAccount(account) {
const config = await this.accountStore.get(account.id);
const isConfigured = Boolean(config?.baseUrl);
await this.cloudFileApi.updateAccount(account.id, {
configured: isConfigured,
managementUrl: this.managementUrlFor(account.id),
name: config?.displayName || account.name || "PsiTransfer",
uploadSizeLimit: -1,
spaceRemaining: -1,
spaceUsed: -1
});
debug("Ensured account state", account.id, { isConfigured });
}
async onAccountAdded(account) {
info("CloudFile account added", account.id);
const existing = await this.accountStore.get(account.id);
if (!existing) {
await this.accountStore.set(account.id, {
baseUrl: "",
uploadAppPath: "/",
uploadPassword: "",
defaultRetention: "604800",
customSid: ""
});
}
await this.ensureAccount(account);
}
async onAccountDeleted(accountId) {
info("CloudFile account deleted", accountId);
await this.accountStore.remove(accountId);
}
async onFileUpload(account, fileInfo, tab, relatedFileInfo) {
debug("Upload requested", {
accountId: account.id,
fileId: fileInfo.id,
relatedFileInfo
});
if (this.#consumeAbort(account.id, fileInfo.id)) {
return { aborted: true };
}
const config = await this.accountStore.get(account.id);
if (!config?.baseUrl) {
return {
error:
"PsiTransfer account is not configured. Open the provider settings and set the base PsiTransfer URL."
};
}
try {
const client = new PsiTransferClient(config);
const uploadResult = await client.uploadFile(fileInfo.data, {
uploadId: this.#uploadKey(account.id, fileInfo.id),
customSid: config.customSid || undefined,
retention: config.defaultRetention || undefined,
uploadPassword: config.uploadPassword || undefined,
relatedUrl: relatedFileInfo?.url
});
const url = client.resolveShareUrl(uploadResult);
const remoteRef = {
fileId: String(fileInfo.id),
sid: uploadResult.sid,
key: uploadResult.key,
url,
retention: uploadResult.retention || config.defaultRetention,
downloadPasswordProtected: false,
deleteSupported: false,
renameSupported: false
};
await this.accountStore.setUploadedFile(account.id, fileInfo.id, remoteRef);
return {
url,
templateInfo: this.#buildTemplateInfo(account, remoteRef)
};
} catch (caughtError) {
if (this.#consumeAbort(account.id, fileInfo.id)) {
return { aborted: true };
}
logError("Upload failed", caughtError);
return {
error: caughtError instanceof Error ? caughtError.message : String(caughtError)
};
}
}
async onFileUploadAbort(account, fileId) {
warn("Upload abort requested", { accountId: account.id, fileId });
this.abortedUploads.add(this.#uploadKey(account.id, fileId));
const config = await this.accountStore.get(account.id);
if (!config?.baseUrl) {
return;
}
try {
const client = new PsiTransferClient(config);
await client.abortUpload(this.#uploadKey(account.id, fileId));
} catch (caughtError) {
debug("Abort not fully implemented yet", caughtError);
}
}
async onFileDeleted(account, fileId) {
warn("Delete requested", { accountId: account.id, fileId });
const config = await this.accountStore.get(account.id);
const remoteRef = await this.accountStore.getUploadedFile(account.id, fileId);
if (!config?.baseUrl || !remoteRef) {
return;
}
try {
const client = new PsiTransferClient(config);
await client.deleteUpload(remoteRef);
await this.accountStore.removeUploadedFile(account.id, fileId);
} catch (caughtError) {
debug("Delete not implemented yet", caughtError);
}
}
async onFileRename(account, fileId, newName) {
warn("Rename requested", { accountId: account.id, fileId, newName });
const config = await this.accountStore.get(account.id);
const remoteRef = await this.accountStore.getUploadedFile(account.id, fileId);
if (!config?.baseUrl || !remoteRef) {
return {
error: "No stored PsiTransfer upload record was found for this Thunderbird file."
};
}
try {
const client = new PsiTransferClient(config);
const result = await client.renameUpload(remoteRef, newName);
const url = client.resolveShareUrl(result || remoteRef);
await this.accountStore.setUploadedFile(account.id, fileId, {
...remoteRef,
...result,
url
});
return { url };
} catch (caughtError) {
return {
error: caughtError instanceof Error ? caughtError.message : String(caughtError)
};
}
}
managementUrlFor(accountId) {
return globalThis.browser.runtime.getURL(`ui/account.html?accountId=${encodeURIComponent(accountId)}`);
}
#buildTemplateInfo(account, remoteRef) {
const templateInfo = {
service_name: account.name || "PsiTransfer",
service_url: remoteRef.url
};
if (remoteRef.downloadPasswordProtected) {
templateInfo.download_password_protected = true;
}
const retentionSeconds = Number(remoteRef.retention);
if (Number.isFinite(retentionSeconds) && retentionSeconds > 0) {
templateInfo.download_expiry_date = {
timestamp: Date.now() + retentionSeconds * 1000
};
}
if (remoteRef.retention === "one-time") {
templateInfo.download_limit = 1;
}
return templateInfo;
}
#uploadKey(accountId, fileId) {
return `${accountId}:${fileId}`;
}
#consumeAbort(accountId, fileId) {
const key = this.#uploadKey(accountId, fileId);
const aborted = this.abortedUploads.has(key);
if (aborted) {
this.abortedUploads.delete(key);
}
return aborted;
}
}

274
src/psitransfer/client.js Normal file
View file

@ -0,0 +1,274 @@
import "../../vendor/tus.js";
import { PSI_TRANSFER_NOT_IMPLEMENTED } from "./types.js";
const tus = globalThis.tus;
const activeUploads = new Map();
const DEFAULT_CHUNK_SIZE = 5_000_000;
function normalizeBaseUrl(rawUrl) {
const url = new URL(rawUrl);
if (!url.pathname.endsWith("/")) {
url.pathname += "/";
}
return url;
}
function randomSid() {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");
}
function normalizeUploadAppPath(rawPath) {
const value = String(rawPath || "/").trim();
if (!value || value === "/") {
return "/";
}
if (/^[a-z]+:\/\//i.test(value)) {
return normalizeBaseUrl(value).toString();
}
return `${value.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
}
function resolveUploadAppUrl(baseUrl, rawPath) {
const normalizedPath = normalizeUploadAppPath(rawPath);
if (/^[a-z]+:\/\//i.test(normalizedPath)) {
return normalizedPath;
}
return normalizedPath === "/" ? normalizeBaseUrl(baseUrl).toString() : new URL(normalizedPath, normalizeBaseUrl(baseUrl)).toString();
}
function absolutizeUrl(baseUrl, candidate) {
if (!candidate) {
return null;
}
return new URL(candidate, baseUrl).toString();
}
function parseUploadLocation(uploadUrl) {
if (!uploadUrl) {
return {};
}
const pathname = new URL(uploadUrl).pathname.replace(/\/+$/, "");
const uploadId = decodeURIComponent(pathname.substring(pathname.lastIndexOf("/") + 1));
if (!uploadId.includes("++")) {
return { uploadId };
}
const [sid, key] = uploadId.split("++");
return { sid, key, uploadId };
}
async function readErrorBody(response) {
try {
return await response.text();
} catch {
return "";
}
}
function describeTusError(caughtError) {
const response = caughtError?.originalResponse;
const status = response?.getStatus?.();
const body = response?.getBody?.();
if (body) {
try {
const parsed = JSON.parse(body);
if (parsed?.message) {
return `PsiTransfer upload failed (${status || "unknown"}): ${parsed.message}`;
}
} catch {
return `PsiTransfer upload failed (${status || "unknown"}): ${body}`;
}
return `PsiTransfer upload failed (${status || "unknown"}): ${body}`;
}
if (status) {
return `PsiTransfer upload failed (${status}).`;
}
return caughtError instanceof Error ? caughtError.message : String(caughtError);
}
export class PsiTransferClient {
constructor(config) {
if (!config?.baseUrl) {
throw new Error("PsiTransferClient requires a baseUrl.");
}
this.config = {
...config,
baseUrl: normalizeBaseUrl(config.baseUrl).toString(),
uploadAppPath: config.uploadAppPath || "/"
};
if (!tus?.Upload) {
throw new Error("Vendored tus client did not load. Verify vendor/tus.js is packaged and accessible to the background service worker.");
}
}
async fetchServerConfig(options = {}) {
const endpoint = new URL("config.json", this.config.baseUrl);
const response = await fetch(endpoint, {
headers: this.#buildHeaders(options.uploadPassword)
});
if (!response.ok) {
const body = await readErrorBody(response);
throw new Error(
`PsiTransfer config probe failed (${response.status}). ${body || "Check the base URL and upload password."}`.trim()
);
}
return response.json();
}
async buildUploadPlan(file, options = {}) {
const baseUrl = normalizeBaseUrl(this.config.baseUrl);
const uploadAppUrl = resolveUploadAppUrl(baseUrl, options.uploadAppPath || this.config.uploadAppPath);
const serverConfig = await this.fetchServerConfig(options);
const sid = options.customSid || this.config.customSid || randomSid();
const retention = options.retention || this.config.defaultRetention || serverConfig.defaultRetention || "604800";
const uploadEndpoint = new URL("files", uploadAppUrl).toString();
const lockEndpoint = new URL(`files/${encodeURIComponent(sid)}?lock=yes`, uploadAppUrl).toString();
const shareUrl = new URL(encodeURIComponent(sid), baseUrl).toString();
const supportedRetentions = Object.keys(serverConfig?.retentions || {});
if (serverConfig?.requireBucketPassword && !options.bucketPassword) {
throw new Error("This PsiTransfer server requires a bucket password, but the Thunderbird provider does not expose bucket-password configuration yet.");
}
if (serverConfig?.maxFileSize && file.size > Number(serverConfig.maxFileSize)) {
throw new Error(`File exceeds the PsiTransfer max upload size of ${serverConfig.maxFileSize} bytes.`);
}
if (supportedRetentions.length && !supportedRetentions.includes(retention)) {
throw new Error(`Retention "${retention}" is not accepted by this PsiTransfer server. Supported values: ${supportedRetentions.join(", ")}.`);
}
return {
sid,
shareUrl,
uploadAppUrl,
uploadEndpoint,
lockEndpoint,
headers: this.#buildHeaders(options.uploadPassword),
retention,
metadata: {
sid,
retention,
name: file.name,
type: file.type || "application/octet-stream",
...(options.bucketPassword ? { password: options.bucketPassword } : {})
},
sourceNote:
"Derived from /tmp/psitransfer/lib/endpoints.js:133-158,418-552, /tmp/psitransfer/lib/tusboy/handlers/post.js, and /tmp/psitransfer/app/src/Upload/store/upload.js:127-246. PsiTransfer expects tus metadata including name, sid, retention, and optional password, then a bucket lock PATCH once uploads complete."
};
}
async uploadFile(file, options = {}) {
const plan = await this.buildUploadPlan(file, options);
const uploadId = String(options.uploadId || `${plan.sid}:${file.name}:${Date.now()}`);
return new Promise((resolve, reject) => {
let createdUploadUrl = null;
const cleanup = () => {
activeUploads.delete(uploadId);
};
const upload = new tus.Upload(file, {
endpoint: plan.uploadEndpoint,
headers: plan.headers,
metadata: plan.metadata,
chunkSize: DEFAULT_CHUNK_SIZE,
parallelUploads: 1,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000, 5000],
onAfterResponse(req, res) {
if (req.getMethod() === "POST" && res.getStatus() === 201) {
createdUploadUrl = absolutizeUrl(plan.uploadAppUrl, res.getHeader("location"));
}
},
onError(caughtError) {
cleanup();
reject(new Error(describeTusError(caughtError)));
},
onSuccess: async () => {
const finalUploadUrl = upload.url || createdUploadUrl;
const uploadInfo = parseUploadLocation(finalUploadUrl);
try {
await this.#lockBucket(plan.lockEndpoint, plan.headers);
cleanup();
resolve({
sid: uploadInfo.sid || plan.sid,
key: uploadInfo.key,
uploadId: uploadInfo.uploadId,
uploadUrl: finalUploadUrl,
retention: plan.retention,
url: plan.shareUrl
});
} catch (caughtError) {
cleanup();
reject(caughtError);
}
}
});
activeUploads.set(uploadId, upload);
upload.start();
});
}
async abortUpload(uploadId) {
const upload = activeUploads.get(uploadId);
if (!upload) {
return false;
}
await Promise.resolve(upload.abort(true));
activeUploads.delete(uploadId);
return true;
}
async deleteUpload(remoteRef) {
throw new Error(
`${PSI_TRANSFER_NOT_IMPLEMENTED} Delete support is not wired because the PsiTransfer source inspected so far does not establish a stable public delete endpoint for previously uploaded files. Remote ref: ${JSON.stringify(remoteRef)}`
);
}
async renameUpload(remoteRef, newName) {
throw new Error(
`${PSI_TRANSFER_NOT_IMPLEMENTED} Rename support is not wired because the PsiTransfer source inspected so far does not establish a stable public rename endpoint. Requested name: ${newName}; remote ref: ${JSON.stringify(remoteRef)}`
);
}
resolveShareUrl(result) {
if (result?.url) {
return result.url;
}
if (result?.sid) {
return new URL(encodeURIComponent(result.sid), normalizeBaseUrl(this.config.baseUrl)).toString();
}
throw new Error("Cannot resolve PsiTransfer share URL without a url or sid.");
}
#buildHeaders(uploadPassword) {
const password = uploadPassword ?? this.config.uploadPassword;
return password ? { "x-passwd": password } : {};
}
async #lockBucket(lockEndpoint, headers) {
const response = await fetch(lockEndpoint, {
method: "PATCH",
headers
});
if (!response.ok) {
const body = await readErrorBody(response);
throw new Error(`PsiTransfer bucket lock failed (${response.status}). ${body}`.trim());
}
}
}

48
src/psitransfer/types.js Normal file
View file

@ -0,0 +1,48 @@
/**
* @typedef {Object} PsiTransferAccountConfig
* @property {string} baseUrl
* @property {string=} uploadAppPath
* @property {string=} uploadPassword
* @property {string=} defaultRetention
* @property {string=} customSid
*/
/**
* Source-informed upload contract derived from /tmp/psitransfer.
*
* @typedef {Object} PsiTransferUploadRequest
* @property {File} file
* @property {string} fileId
* @property {string} sid
* @property {string} retention
* @property {string=} bucketPassword
* @property {string=} uploadPassword
* @property {string=} relatedUrl
*/
/**
* @typedef {Object} PsiTransferUploadPlan
* @property {string} sid
* @property {string} shareUrl
* @property {string} uploadAppUrl
* @property {string} uploadEndpoint
* @property {string} lockEndpoint
* @property {Object<string, string>} headers
* @property {Object<string, string>} metadata
* @property {string} retention
* @property {string} sourceNote
*/
/**
* @typedef {Object} PsiTransferRemoteFileRef
* @property {string} fileId
* @property {string} sid
* @property {string=} key
* @property {string} url
* @property {string=} retention
* @property {boolean=} downloadPasswordProtected
* @property {boolean=} deleteSupported
* @property {boolean=} renameSupported
*/
export const PSI_TRANSFER_NOT_IMPLEMENTED = "PsiTransfer integration is not implemented yet.";

21
src/util/log.js Normal file
View file

@ -0,0 +1,21 @@
const PREFIX = "[psitransfer-filelink]";
function format(args) {
return [PREFIX, ...args];
}
export function debug(...args) {
console.debug(...format(args));
}
export function info(...args) {
console.info(...format(args));
}
export function warn(...args) {
console.warn(...format(args));
}
export function error(...args) {
console.error(...format(args));
}