Add initial PsiTransfer Filelink provider scaffold
This commit is contained in:
parent
a87d020ca2
commit
953cd501a7
16 changed files with 6283 additions and 1 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
release/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
39
README.md
39
README.md
|
|
@ -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
75
build-xpi.ps1
Normal 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
79
build-xpi.sh
Executable 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
124
docs/psitransfer-notes.md
Normal 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
29
manifest.json
Normal 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
30
src/background.js
Normal 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);
|
||||||
|
});
|
||||||
70
src/cloudfile/account-store.js
Normal file
70
src/cloudfile/account-store.js
Normal 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
223
src/cloudfile/provider.js
Normal 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
274
src/psitransfer/client.js
Normal 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
48
src/psitransfer/types.js
Normal 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
21
src/util/log.js
Normal 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
141
ui/account.css
Normal 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
65
ui/account.html
Normal 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
70
ui/account.js
Normal 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
4989
vendor/tus.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue