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

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
release/
.DS_Store
Thumbs.db
.idea/
.vscode/

View file

@ -1,3 +1,40 @@
# FileLink-PsiTransfer # FileLink-PsiTransfer
FileLink adapter for Thunderbird Thunderbird Manifest V3 Filelink/cloudFile provider for PsiTransfer.
Current status: minimal v1 upload path implemented. The extension now performs a source-informed tus upload, locks the PsiTransfer bucket, and returns the bucket share URL to Thunderbird. Delete, rename, and upload reuse remain intentionally unimplemented until the PsiTransfer source proves a stable provider-facing contract.
The real integration contract must be derived from:
- `/tmp/psitransfer`
- https://webextension-api.thunderbird.net/en/mv3/cloudFile.html
This repository already includes:
- `manifest.json` for a Thunderbird MV3 `cloud_file` provider
- background and provider scaffolding under `src/`
- a minimal account management UI under `ui/`
- a vendored local copy of `tus-js-client` at `vendor/tus.js`
- implementation notes in `docs/psitransfer-notes.md`
- agent workflow guidance in `AGENTS.md`
Current provider behavior:
1. Read `GET /config.json` from the configured base URL with the optional `x-passwd` header.
2. Upload one Thunderbird attachment to one PsiTransfer bucket using the vendored tus client.
3. Lock the bucket with `PATCH /files/:sid?lock=yes`.
4. Return the bucket share URL `/<sid>` to Thunderbird.
Configuration notes:
- `baseUrl` should point at the PsiTransfer download/share root.
- `uploadAppPath` is optional and defaults to `/`. Use it only when the server mounts uploads under a subpath relative to `baseUrl`, because `config.json` does not expose `uploadAppPath`.
- The provider validates retention values and max file size against `config.json`.
- If the PsiTransfer server requires bucket passwords, the provider currently stops with an explicit error because bucket-password UI is not implemented yet.
Building:
- Run `./build-xpi.sh` on Unix-like systems or `powershell ./build-xpi.ps1` on Windows.
- The scripts read the version from `manifest.json` and write `release/psitransfer-filelink-<version>.xpi`.
Do not assume PsiTransfer exposes a polished external API. This scaffold is intentionally conservative and keeps unresolved behavior as explicit TODOs.

75
build-xpi.ps1 Normal file
View file

@ -0,0 +1,75 @@
<#
.SYNOPSIS
Build an XPI from the repository contents.
.DESCRIPTION
- Reads the version from manifest.json.
- Packages the repo while excluding build scripts, release output, and local editor folders.
- Writes the finished XPI to release/.
#>
$ScriptDir = Split-Path $MyInvocation.MyCommand.Path
$ReleaseDir = Join-Path $ScriptDir 'release'
$Manifest = Join-Path $ScriptDir 'manifest.json'
if (-not (Test-Path $Manifest)) {
Write-Error "manifest.json not found at $Manifest"
exit 1
}
if (-not (Test-Path $ReleaseDir)) {
New-Item -ItemType Directory -Path $ReleaseDir | Out-Null
}
$version = (Get-Content $Manifest -Raw | ConvertFrom-Json).version
if (-not $version) {
Write-Error "No version found in manifest.json"
exit 1
}
$artifactBase = "psitransfer-filelink-$version"
$zipPath = Join-Path $ReleaseDir "$artifactBase.zip"
$xpiPath = Join-Path $ReleaseDir "$artifactBase.xpi"
Remove-Item -Path $zipPath, $xpiPath -Force -ErrorAction SilentlyContinue
$allFiles = Get-ChildItem -Path $ScriptDir -Recurse -File |
Where-Object {
$_.Extension -notin '.ps1', '.sh' -and
$_.FullName -notmatch '\\release\\' -and
$_.FullName -notmatch '\\.git\\' -and
$_.FullName -notmatch '\\.idea\\' -and
$_.FullName -notmatch '\\.vscode\\'
}
if ($allFiles.Count -eq 0) {
Write-Error "No files found to package."
exit 1
}
foreach ($file in $allFiles) {
$size = (Get-Item $file.FullName).Length
$rel = $file.FullName.Substring($ScriptDir.Length + 1).TrimStart('\')
$entryName = $rel.Replace('\', '/')
Write-Host "Zipping: $entryName <- $($file.FullName) ($size bytes)"
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::Open($zipPath, 'Create')
foreach ($file in $allFiles) {
$rel = $file.FullName.Substring($ScriptDir.Length + 1).TrimStart('\')
$entryName = $rel.Replace('\', '/')
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip,
$file.FullName,
$entryName,
[System.IO.Compression.CompressionLevel]::Optimal
)
}
$zip.Dispose()
Rename-Item -Path $zipPath -NewName "$artifactBase.xpi" -Force
Write-Host "Built XPI at: $xpiPath"

79
build-xpi.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
release_dir="$script_dir/release"
manifest="$script_dir/manifest.json"
if [[ ! -f "$manifest" ]]; then
echo "manifest.json not found at $manifest" >&2
exit 1
fi
if ! command -v zip >/dev/null 2>&1; then
echo "zip is required to build the XPI." >&2
exit 1
fi
if command -v jq >/dev/null 2>&1; then
version="$(jq -r '.version // empty' "$manifest")"
else
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is required to read manifest.json without jq." >&2
exit 1
fi
version="$(python3 - "$manifest" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
data = json.load(f)
print(data.get("version", "") or "")
PY
)"
fi
if [[ -z "$version" ]]; then
echo "No version found in manifest.json" >&2
exit 1
fi
mkdir -p "$release_dir"
artifact_base="psitransfer-filelink-$version"
zip_path="$release_dir/$artifact_base.zip"
xpi_path="$release_dir/$artifact_base.xpi"
rm -f "$zip_path" "$xpi_path"
mapfile -d '' files < <(
find "$script_dir" -type f \
! -name '*.ps1' \
! -name '*.sh' \
! -path "$release_dir/*" \
! -path "$script_dir/.git/*" \
! -path "$script_dir/.idea/*" \
! -path "$script_dir/.vscode/*" \
-printf '%P\0'
)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files found to package." >&2
exit 1
fi
for rel in "${files[@]}"; do
full="$script_dir/$rel"
size=$(stat -c '%s' "$full")
echo "Zipping: $rel <- $full ($size bytes)"
done
(
cd "$script_dir"
printf '%s\n' "${files[@]}" | zip -q -9 -@ "$zip_path"
)
mv -f "$zip_path" "$xpi_path"
echo "Built XPI at: $xpi_path"

124
docs/psitransfer-notes.md Normal file
View file

@ -0,0 +1,124 @@
# PsiTransfer Notes
This document records the PsiTransfer behavior inferred from the local source at `/tmp/psitransfer`. It is intentionally narrow and only covers what was needed for the Thunderbird Filelink provider minimal v1 upload path.
## Confirmed upload flow
1. PsiTransfer exposes upload-related frontend config from `GET /config.json`.
Source:
`/tmp/psitransfer/lib/endpoints.js:133-158`
2. PsiTransfer protects both `GET /config.json` and the upload mount with the `x-passwd` header when `uploadPass` is configured.
Source:
`/tmp/psitransfer/lib/endpoints.js:134-142`
`/tmp/psitransfer/lib/endpoints.js:420-428`
3. The upload mount is `${config.uploadAppPath}files`.
Source:
`/tmp/psitransfer/lib/endpoints.js:418-419`
4. PsiTransfer uses tus semantics for upload creation and patching.
Source:
`/tmp/psitransfer/lib/endpoints.js:479-552`
`/tmp/psitransfer/lib/tusboy/handlers/post.js`
`/tmp/psitransfer/lib/tusboy/handlers/patch.js`
5. On upload creation, PsiTransfer expects `Upload-Metadata` to include at least:
- `name`
- `sid`
- `retention`
Optional metadata observed in the frontend or server path:
- `password`
- `comment`
- `type`
Source:
`/tmp/psitransfer/lib/endpoints.js:480-531`
`/tmp/psitransfer/app/src/Upload/store/upload.js:133-151`
6. PsiTransfer generates a random per-file key server-side and uses `sid++key` as the upload identifier.
Source:
`/tmp/psitransfer/lib/endpoints.js:506-531`
7. After all uploads in a bucket complete, the PsiTransfer frontend issues `PATCH /files/:sid?lock=yes`.
Source:
`/tmp/psitransfer/app/src/Upload/store/upload.js:242-245`
`/tmp/psitransfer/lib/endpoints.js:440-446`
8. The user-facing share URL is the bucket URL `/<sid>`, not the raw file URL under `/files/...`.
Source:
`/tmp/psitransfer/app/src/Upload/store/upload.js:42-44`
`/tmp/psitransfer/lib/endpoints.js:199-246`
9. The PsiTransfer frontend remembers the `Location` header from the tus `POST` response and then reuses that upload URL for the file PATCHes.
Source:
`/tmp/psitransfer/app/src/Upload/store/upload.js:145-167`
`/tmp/psitransfer/lib/tusboy/handlers/post.js:1-73`
10. PsiTransfer stores the upload resource at `/files/<sid>++<key>` and responds to tus `POST` with a relative `Location` header under the upload mount.
Source:
`/tmp/psitransfer/lib/tusboy/handlers/post.js:49-73`
`/tmp/psitransfer/lib/endpoints.js:506-531`
## Confirmed configuration semantics
- Default configuration uses `baseUrl: "/"` and `uploadAppPath: "/"`, then normalizes `uploadAppPath` relative to `baseUrl`.
Source:
`/tmp/psitransfer/config.js:9-14`
`/tmp/psitransfer/config.js:36-46`
`/tmp/psitransfer/config.js:86-89`
- `config.json` does not include `uploadAppPath`, so a client cannot discover a non-default upload subpath from that endpoint alone.
Source:
`/tmp/psitransfer/lib/endpoints.js:145-155`
`/tmp/psitransfer/config.js:11-13`
`/tmp/psitransfer/config.js:86-89`
- Default retention is `"604800"` and supported retention values come from `config.retentions`.
Source:
`/tmp/psitransfer/config.js:25-34`
`/tmp/psitransfer/config.js:39-42`
`/tmp/psitransfer/lib/endpoints.js:148-155`
`/tmp/psitransfer/lib/endpoints.js:488-490`
- The PsiTransfer frontend supports an explicit bucket id through the `sid` query parameter.
Source:
`/tmp/psitransfer/README.md:28`
`/tmp/psitransfer/app/src/Upload/store/upload.js:20-30`
## Confirmed download/share hints relevant to Thunderbird
- Bucket JSON is available at `/<sid>.json` and includes per-item URLs under `/files/<sid>++<key>`.
Source:
`/tmp/psitransfer/lib/endpoints.js:199-238`
- Password-protected buckets are a real server-side concern; Thunderbird template hints can likely use `download_password_protected`.
Source:
`/tmp/psitransfer/lib/endpoints.js:212-225`
- One-time retention is implemented server-side and can map to Thunderbird `download_limit: 1`.
Source:
`/tmp/psitransfer/config.js:25-34`
`/tmp/psitransfer/lib/endpoints.js:294-300`
`/tmp/psitransfer/lib/endpoints.js:392-395`
## Not yet established
- A stable public delete endpoint for previously uploaded files suitable for Thunderbird `onFileDeleted`.
- A stable public rename endpoint suitable for Thunderbird `onFileRename`.
- Whether the Thunderbird provider should upload one file per bucket or group related files into a shared bucket. PsiTransfer is bucket-oriented, while Thunderbird `cloudFile` uploads are file-oriented.
- Whether Thunderbird MV3 background service workers expose every browser primitive expected by the vendored `tus-js-client` bundle in all supported Thunderbird versions. The current implementation assumes the vendored bundle runs in the background worker.
## Implemented minimal-v1 mapping
- Thunderbird configuration drives `baseUrl`, `uploadAppPath`, `defaultRetention`, and the optional upload password header.
- The provider probes `GET <baseUrl>/config.json` before upload to validate retention, max file size, and bucket-password requirements.
- The provider uses the vendored `vendor/tus.js` bundle for the tus `POST` and `PATCH` flow.
- After the tus upload completes, the provider sends `PATCH <uploadAppPath>/files/<sid>?lock=yes`.
- The provider returns the bucket share URL `<baseUrl>/<sid>` and Thunderbird `templateInfo` hints for retention and one-time downloads.
## Implementation guidance
- Treat tus behavior as standard transport behavior, not a PsiTransfer-specific API by itself.
- Treat the bucket lock `PATCH ...?lock=yes` as PsiTransfer-specific behavior.
- Treat Thunderbird `cloudFile` event handling and return objects as a separate concern from the PsiTransfer transport.
- Prefer a minimal first implementation: configure endpoint, upload a single file, return the bucket share URL, and stop there until delete or rename support is proven.

29
manifest.json Normal file
View file

@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "PsiTransfer Filelink",
"version": "0.1.0",
"description": "Thunderbird Filelink provider scaffold for PsiTransfer.",
"browser_specific_settings": {
"gecko": {
"id": "psitransfer-filelink@example.invalid",
"strict_min_version": "128.0"
}
},
"permissions": [
"cloudFile"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "src/background.js",
"type": "module"
},
"cloud_file": {
"name": "PsiTransfer",
"management_url": "ui/account.html",
"browser_style": false,
"reuse_uploads": false
}
}

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));
}

141
ui/account.css Normal file
View file

@ -0,0 +1,141 @@
:root {
color-scheme: light;
--bg: #f5efe5;
--paper: #fffaf2;
--ink: #1d1c1a;
--muted: #6b6257;
--accent: #b3541e;
--accent-2: #e8c9a8;
--line: #dcc9b5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, #f3dcc3, transparent 28%),
linear-gradient(180deg, #efe5d8 0%, var(--bg) 100%);
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero {
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
font-size: 0.78rem;
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 0.95;
}
.intro,
.hint,
.status {
color: var(--muted);
}
.card {
background: color-mix(in srgb, var(--paper) 92%, white);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 16px 40px rgba(74, 45, 16, 0.08);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
form {
display: grid;
gap: 14px;
}
label {
display: grid;
gap: 6px;
}
label span {
font-weight: 600;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fffdf9;
color: var(--ink);
font: inherit;
}
input:focus {
outline: 2px solid color-mix(in srgb, var(--accent) 45%, white);
outline-offset: 1px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 18px;
background: var(--accent);
color: white;
font: inherit;
cursor: pointer;
}
button:hover {
background: #954515;
}
.status {
min-height: 1.5em;
margin: 14px 0 0;
}
code {
background: var(--accent-2);
padding: 2px 6px;
border-radius: 6px;
}
@media (max-width: 560px) {
.page {
padding: 20px 14px 32px;
}
.meta {
flex-direction: column;
}
}

65
ui/account.html Normal file
View file

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PsiTransfer Account</title>
<link rel="stylesheet" href="account.css" />
</head>
<body>
<main class="page">
<header class="hero">
<p class="eyebrow">Thunderbird Filelink</p>
<h1>PsiTransfer Account</h1>
<p class="intro">
Local account settings for the PsiTransfer Filelink provider. The minimal v1 path uploads one Thunderbird attachment into one PsiTransfer bucket and returns the bucket share URL.
</p>
</header>
<section class="card">
<div class="meta">
<span>Account ID</span>
<code id="account-id">unknown</code>
</div>
<form id="account-form">
<label>
<span>Base PsiTransfer URL</span>
<input id="base-url" name="baseUrl" type="url" placeholder="https://files.example.com/" required />
</label>
<label>
<span>Upload app path</span>
<input id="upload-app-path" name="uploadAppPath" type="text" placeholder="/ or upload/" />
</label>
<label>
<span>Optional upload password</span>
<input id="upload-password" name="uploadPassword" type="password" placeholder="x-passwd header value" />
</label>
<label>
<span>Default retention / expiry</span>
<input id="default-retention" name="defaultRetention" type="text" placeholder="604800 or one-time" />
</label>
<label>
<span>Custom SID behavior</span>
<input id="custom-sid" name="customSid" type="text" placeholder="Leave blank for generated SID" />
</label>
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
<p id="status" class="status" role="status"></p>
<p class="hint">
`uploadAppPath` defaults to `/`. Set it only if the PsiTransfer server mounts uploads under a subpath relative to the base URL.
</p>
</section>
</main>
<script type="module" src="account.js"></script>
</body>
</html>

70
ui/account.js Normal file
View file

@ -0,0 +1,70 @@
import { AccountStore } from "../src/cloudfile/account-store.js";
const accountId = new URL(location.href).searchParams.get("accountId");
const form = document.getElementById("account-form");
const statusNode = document.getElementById("status");
const accountIdNode = document.getElementById("account-id");
const accountStore = new AccountStore();
function setStatus(message, isError = false) {
statusNode.textContent = message;
statusNode.style.color = isError ? "#8a1f11" : "";
}
function formDataToConfig(formElement) {
const data = new FormData(formElement);
return {
baseUrl: String(data.get("baseUrl") || "").trim(),
uploadAppPath: String(data.get("uploadAppPath") || "").trim() || "/",
uploadPassword: String(data.get("uploadPassword") || "").trim(),
defaultRetention: String(data.get("defaultRetention") || "").trim() || "604800",
customSid: String(data.get("customSid") || "").trim()
};
}
async function load() {
if (!accountId) {
setStatus("Missing Thunderbird cloudFile accountId.", true);
form.querySelector("button").disabled = true;
return;
}
accountIdNode.textContent = accountId;
const config = await accountStore.get(accountId);
if (!config) {
return;
}
document.getElementById("base-url").value = config.baseUrl || "";
document.getElementById("upload-app-path").value = config.uploadAppPath || "/";
document.getElementById("upload-password").value = config.uploadPassword || "";
document.getElementById("default-retention").value = config.defaultRetention || "604800";
document.getElementById("custom-sid").value = config.customSid || "";
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!accountId) {
setStatus("Cannot save without an accountId.", true);
return;
}
try {
const config = formDataToConfig(form);
await accountStore.set(accountId, config);
await browser.cloudFile.updateAccount(accountId, {
configured: Boolean(config.baseUrl),
managementUrl: browser.runtime.getURL(`ui/account.html?accountId=${encodeURIComponent(accountId)}`)
});
setStatus("Settings saved.");
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : String(caughtError), true);
}
});
load().catch(caughtError => {
setStatus(caughtError instanceof Error ? caughtError.message : String(caughtError), true);
});

4989
vendor/tus.js vendored Normal file

File diff suppressed because one or more lines are too long