filenymous 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -2
- package/dist/filenymous.js +37 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,124 @@
|
|
|
1
|
-
#
|
|
1
|
+
# filenymous — CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Partage de fichiers chiffré de bout en bout, **100 % pair-à-pair**, depuis le terminal ou la CI.
|
|
4
|
+
End-to-end encrypted, **100 % peer-to-peer** file sharing from your terminal or CI.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx filenymous upload app.zip --ttl 1h
|
|
8
|
+
# → https://filenymous.eu/app/#p2p:123-456-ABC/xxxxxxxxxxxxxxxxxxxxxx
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/filenymous)
|
|
12
|
+
|
|
13
|
+
**Installation** : `npx filenymous …` (sans installation) ou `npm i -g filenymous` — Node ≥ 18.17.
|
|
14
|
+
|
|
15
|
+
[Français](#français) · [English](#english) · [GitHub Actions](#github-actions)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Français
|
|
20
|
+
|
|
21
|
+
### Principe
|
|
22
|
+
- La CLI est un **pair émetteur** : elle utilise exactement le protocole du transfert direct de l'application web (lien `#p2p:CODE/SECRET`, AES-256-GCM par bloc de 64 Kio, mot de passe optionnel).
|
|
23
|
+
- Le lien s'ouvre dans n'importe quel navigateur sur https://filenymous.eu/app/, ou avec `filenymous receive`.
|
|
24
|
+
- **Rien n'est stocké sur un serveur.** Le relais de signalisation (`wss://www.opendpe.net/filenymous-signal/`) ne fait que mettre les deux pairs en relation. Le fichier passe en WebRTC, directement ou via TURN, et toujours chiffré.
|
|
25
|
+
- Conséquence : **le processus doit rester actif** jusqu'à la fin des téléchargements. Il s'arrête de lui-même après `--max-downloads` téléchargements ou à l'expiration du `--ttl`.
|
|
26
|
+
|
|
27
|
+
### Envoyer
|
|
28
|
+
```bash
|
|
29
|
+
filenymous upload <fichier...> [--ttl 1h] [--max-downloads 1] [--password X] \
|
|
30
|
+
[--webhook URL] [--webhook-no-link] [--allow-empty] [--json] [-q]
|
|
31
|
+
```
|
|
32
|
+
- Le lien est écrit sur la **sortie standard**, la progression sur stderr. Avec `--json`, la sortie standard reçoit un événement JSON par ligne.
|
|
33
|
+
- `--stream` : le destinataire web peut lire la vidéo ou l'audio pendant le transfert (« Partage streamé »).
|
|
34
|
+
- `--password` : un mot de passe est demandé au destinataire. Il n'est jamais envoyé : c'est une preuve HMAC, et une dérivation PBKDF2 est mêlée à la clé AES. Préférez la variable d'environnement `FILENYMOUS_PASSWORD`, qui n'apparaît pas dans l'historique du shell ni dans les logs de CI.
|
|
35
|
+
- Codes de sortie :
|
|
36
|
+
- `0` : au moins un téléchargement terminé (ou `--allow-empty`)
|
|
37
|
+
- `2` : TTL expiré sans aucun téléchargement
|
|
38
|
+
- `1` : erreur, par exemple 5 mauvais mots de passe
|
|
39
|
+
- `130` : Ctrl-C
|
|
40
|
+
|
|
41
|
+
### Recevoir
|
|
42
|
+
```bash
|
|
43
|
+
filenymous receive "<lien ou code>" [-o dossier] [--password X] [--timeout 30m] [--json]
|
|
44
|
+
```
|
|
45
|
+
- Les fichiers sont écrits en flux dans le dossier, sans jamais écraser un fichier existant (`nom (1).ext`).
|
|
46
|
+
- Les chemins reçus sont affichés sur la sortie standard.
|
|
47
|
+
|
|
48
|
+
### Webhook
|
|
49
|
+
Pour chaque événement, la CLI envoie en `POST` (JSON) :
|
|
50
|
+
```json
|
|
51
|
+
{ "event": "share.created | download.completed | share.expired | share.failed",
|
|
52
|
+
"link": "https://filenymous.eu/app/#p2p:123-456-ABC/…", "code": "123-456-ABC",
|
|
53
|
+
"file": "app.zip", "size": 1234, "expiresAt": "2026-10-16T15:00:00.000Z",
|
|
54
|
+
"downloads": 1, "maxDownloads": 1, "protected": false }
|
|
55
|
+
```
|
|
56
|
+
> ⚠️ **Le lien contient le secret** (après `#`) : quiconque lit le webhook peut télécharger le fichier tant que la CLI tourne. Le **code seul suffit aussi**, car le secret lui est rattaché. Utilisez `--webhook-no-link` pour retirer `link` et `code` du webhook, ou protégez le partage par `--password`.
|
|
57
|
+
|
|
58
|
+
### Options communes
|
|
59
|
+
- `--signal <url>` : relais de signalisation (`FILENYMOUS_SIGNAL_URL`).
|
|
60
|
+
- `--app-url <url>` : base des liens (`FILENYMOUS_APP_URL`).
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## English
|
|
65
|
+
|
|
66
|
+
### How it works
|
|
67
|
+
- The CLI is a **sender peer**: it speaks the web app's direct-transfer protocol unchanged (`#p2p:CODE/SECRET` links, AES-256-GCM per 64 KiB chunk, optional password gate).
|
|
68
|
+
- Links open in any browser at https://filenymous.eu/app/, or with `filenymous receive`.
|
|
69
|
+
- **Nothing is stored server-side.** The signaling relay only introduces the peers; the data flows over WebRTC (direct or TURN-relayed), always encrypted.
|
|
70
|
+
- So **the process must stay alive** until downloads are done. It exits by itself after `--max-downloads` or when `--ttl` expires.
|
|
71
|
+
|
|
72
|
+
### Send
|
|
73
|
+
```bash
|
|
74
|
+
filenymous upload <file...> [--ttl 1h] [--max-downloads 1] [--password X] \
|
|
75
|
+
[--webhook URL] [--webhook-no-link] [--allow-empty] [--json] [-q]
|
|
76
|
+
```
|
|
77
|
+
- The link goes to **stdout** and progress to stderr. With `--json`, stdout carries JSON lines events.
|
|
78
|
+
- `--stream`: web recipients can play audio/video while it downloads ("Streamed sharing").
|
|
79
|
+
- Prefer `FILENYMOUS_PASSWORD` over `--password`, so the password stays out of shell history and CI logs.
|
|
80
|
+
- Exit codes:
|
|
81
|
+
- `0`: at least one completed download (or `--allow-empty`)
|
|
82
|
+
- `2`: TTL expired with no download
|
|
83
|
+
- `1`: error, e.g. 5 wrong passwords
|
|
84
|
+
- `130`: interrupted
|
|
85
|
+
|
|
86
|
+
### Receive
|
|
87
|
+
```bash
|
|
88
|
+
filenymous receive "<link or code>" [-o dir] [--password X] [--timeout 30m] [--json]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Webhook
|
|
92
|
+
The JSON payload is the same as above, POSTed for `share.created`, `download.completed`, `share.expired` and `share.failed`.
|
|
93
|
+
> ⚠️ **The link carries the decryption secret** in its `#fragment`, and **the code alone is enough** too. Anyone who can read your webhook payloads can download the file while the CLI runs. Use `--webhook-no-link` (drops `link` and `code`) or `--password`.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## GitHub Actions
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
- name: Share the build for 1 hour (1 download)
|
|
101
|
+
env:
|
|
102
|
+
FILENYMOUS_PASSWORD: ${{ secrets.SHARE_PASSWORD }} # optional
|
|
103
|
+
run: |
|
|
104
|
+
npx --yes filenymous upload dist/app.zip --ttl 1h --max-downloads 1 \
|
|
105
|
+
--webhook "${{ secrets.SLACK_WEBHOOK_PROXY }}" --webhook-no-link --json > share.jsonl &
|
|
106
|
+
sleep 5 && head -1 share.jsonl | jq -r .code # announce the code, not the link, in public logs
|
|
107
|
+
wait # the job stays alive until the download completes or the TTL expires
|
|
108
|
+
```
|
|
109
|
+
> The runner has to stay up while the recipient downloads (P2P). GitHub-hosted runners reach peers through the relay's TURN servers when a direct path isn't possible.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Technique / Tech notes
|
|
114
|
+
- WebRTC: [`node-datachannel`](https://github.com/murat-dogan/node-datachannel) (libdatachannel, prebuilt binaries, ~1 MB), WebSocket: `ws`. No other runtime dependency.
|
|
115
|
+
- The protocol code is **the web app's own** `ui/src/p2p` (session, protocol, signal), bundled as-is with esbuild, so CLI and browser can't drift apart. Cross-compat is covered by Playwright (CLI → browser, browser → CLI) and a CLI ⇄ CLI loopback test.
|
|
116
|
+
- Node ≥ 18.17.
|
|
117
|
+
|
|
118
|
+
## Développement
|
|
119
|
+
```bash
|
|
120
|
+
cd cli && npm ci
|
|
121
|
+
npm run build # dist/filenymous.js (single file)
|
|
122
|
+
npm test # unit + CLI⇄CLI loopback against a local relay
|
|
123
|
+
cd ../ui && npx playwright test e2e/cli-interop.spec.ts # CLI ⇄ web app
|
|
124
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);
|
|
3
|
+
import{parseArgs as at}from"node:util";import{createInterface as ot}from"node:readline";import{resolve as _e}from"node:path";import{RTCPeerConnection as Re,RTCSessionDescription as Ie,RTCIceCandidate as Me,RTCDataChannel as De}from"node-datachannel/polyfill";import Le from"ws";function Be(s){return s.replace(/^a=/,"")}var N=class extends Re{pendingLocal=0;remoteSet=!1;held=[];userHandler=null;constructor(e){super(e);let t=n=>{let r=n.candidate,i={candidate:r?{candidate:Be(r.candidate),sdpMid:r.sdpMid??"0",sdpMLineIndex:r.sdpMLineIndex??0}:null};this.pendingLocal>0||!this.remoteSet?this.held.push(i):this.userHandler?.(i)};Object.defineProperty(this,"onicecandidate",{configurable:!0,get:()=>this.userHandler?t:null,set:n=>{this.userHandler=n}})}async createOffer(e){return this.pendingLocal=1,super.createOffer()}async createAnswer(e){return this.pendingLocal=1,super.createAnswer()}async setLocalDescription(e){this.pendingLocal=1;try{await super.setLocalDescription(e)}finally{setTimeout(()=>{this.pendingLocal=0,this.flush()},0)}}async setRemoteDescription(e){await super.setRemoteDescription(e),this.remoteSet=!0,this.flush()}flush(){if(!(this.pendingLocal>0||!this.remoteSet))for(let e of this.held.splice(0))this.userHandler?.(e)}};function Q(s){let e=globalThis;e.RTCPeerConnection=N,e.RTCSessionDescription??=Ie,e.RTCIceCandidate??=Me,e.RTCDataChannel??=De,e.WebSocket||(e.WebSocket=Le),e.__FILENYMOUS_SIGNAL_URL__=s}import{open as Ue,stat as Fe}from"node:fs/promises";import{basename as Ne,extname as Oe}from"node:path";var $e={".zip":"application/zip",".gz":"application/gzip",".tgz":"application/gzip",".tar":"application/x-tar",".txt":"text/plain",".log":"text/plain",".json":"application/json",".pdf":"application/pdf",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".mp4":"video/mp4",".html":"text/html"};async function ee(s){let e=await Fe(s);if(!e.isFile())throw new Error(`not a regular file: ${s}`);let t=await Ue(s,"r"),n=e.size;return{name:Ne(s),size:n,type:$e[Oe(s).toLowerCase()]??"application/octet-stream",slice(r,i){let a=Math.max(0,Math.min(n,r)),o=Math.max(a,Math.min(n,i));return{async arrayBuffer(){let c=Buffer.alloc(o-a),l=0;for(;l<c.length;){let{bytesRead:h}=await t.read(c,l,c.length-l,a+l);if(h===0)throw new Error("file changed while sending");l+=h}return c.buffer.slice(c.byteOffset,c.byteOffset+c.length)}}},async close(){await t?.close(),t=null}}}import{createWriteStream as We,existsSync as qe,mkdirSync as Ge,rmSync as ze}from"node:fs";import{extname as Ye,join as fe,basename as Je}from"node:path";var Ke=/^(\d{3})(\d{3})([A-Z]{3})$/,te="ABCDEFGHJKLMNPQRSTUVWXYZ";function S(s){let e="";for(let t=0;t<s.length;t+=32768)e+=String.fromCharCode(...s.subarray(t,t+32768));return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function b(s){let e=s.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(s.length/4)*4,"="),t=atob(e),n=new Uint8Array(t.length);for(let r=0;r<t.length;r++)n[r]=t.charCodeAt(r);return n}function f(s){return s.slice().buffer}function re(){let s=crypto.getRandomValues(new Uint32Array(4)),e=String(s[0]%1e6).padStart(6,"0"),t=[s[1],s[2],s[3]].map(n=>te[n%te.length]).join("");return`${e.slice(0,3)}-${e.slice(3)}-${t}`}function He(s){let t=String(s||"").toUpperCase().replace(/[^0-9A-Z]/g,"").match(Ke);return t?`${t[1]}-${t[2]}-${t[3]}`:null}function ne(){return S(crypto.getRandomValues(new Uint8Array(16)))}function se(s,e,t){return`${s.split("#")[0]}#p2p:${e}${t?`/${t}`:""}`}function ie(s){let e=String(s||"").trim();if(!e)return null;let t=e.indexOf("#");t>=0&&(e=e.slice(t+1));try{e=decodeURIComponent(e)}catch{}e.startsWith("p2p:")&&(e=e.slice(4));let[n,r]=e.split("/"),i=He(n??"");if(!i)return null;let a=r&&/^[A-Za-z0-9_-]{16,64}$/.test(r)?r:void 0;return a?{code:i,secret:a}:{code:i}}function O(s=16){return S(crypto.getRandomValues(new Uint8Array(s)))}async function $(s,e,t=31e4){let n=await crypto.subtle.importKey("raw",f(new TextEncoder().encode(s.normalize("NFC"))),"PBKDF2",!1,["deriveBits"]),r=new Uint8Array(await crypto.subtle.deriveBits({name:"PBKDF2",salt:f(b(e)),iterations:t,hash:"SHA-256"},n,512));return{auth:await crypto.subtle.importKey("raw",f(r.slice(0,32)),{name:"HMAC",hash:"SHA-256"},!1,["sign","verify"]),mix:r.slice(32)}}function ae(s,e){return f(new TextEncoder().encode(`filenymous-p2p-auth-v3:${s}:${e}`))}async function oe(s,e,t){return S(new Uint8Array(await crypto.subtle.sign("HMAC",s.auth,ae(e,t))))}async function ce(s,e,t,n){try{return await crypto.subtle.verify("HMAC",s.auth,f(b(n)),ae(e,t))}catch{return!1}}async function x(s,e){let t=b(s),n=t;e&&(n=new Uint8Array(t.length+e.length),n.set(t),n.set(e,t.length));let r=await crypto.subtle.importKey("raw",f(n),"HKDF",!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:f(new TextEncoder().encode("filenymous-p2p-v2")),info:f(new TextEncoder().encode(e?"file-key+password":"file-key"))},r,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async function le(s){let e=await crypto.subtle.importKey("raw",f(new TextEncoder().encode(`filenymous-p2p-code-v2:${s}`)),"PBKDF2",!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",salt:f(new TextEncoder().encode("filenymous-p2p-secret-wrap")),iterations:12e4,hash:"SHA-256"},e,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async function de(s,e){let t=crypto.getRandomValues(new Uint8Array(12)),n=await crypto.subtle.encrypt({name:"AES-GCM",iv:t},await le(s),f(b(e)));return`${S(t)}.${S(new Uint8Array(n))}`}async function pe(s,e){let[t,n]=String(e||"").split(".");if(!t||!n)throw new Error("invalid wrapped secret");let r=await crypto.subtle.decrypt({name:"AES-GCM",iv:f(b(t))},await le(s),f(b(n)));return S(new Uint8Array(r))}function ue(s,e){let t=new Uint8Array(12),n=new DataView(t.buffer);return n.setUint32(0,s),n.setUint32(4,Math.floor(e/2**32)),n.setUint32(8,e>>>0),t}async function K(s,e,t,n){let r=new Uint8Array(8),i=new DataView(r.buffer);i.setUint32(0,e),i.setUint32(4,t);let a=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:f(ue(e,t)),additionalData:f(r)},s,f(n))),o=new Uint8Array(8+a.byteLength);return o.set(r),o.set(a,8),o.buffer}async function _(s,e){let t=new Uint8Array(e);if(t.byteLength<24)throw new Error("short frame");let n=t.subarray(0,8),r=new DataView(t.buffer,t.byteOffset,8),i=r.getUint32(0),a=r.getUint32(4),o=new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:f(ue(i,a)),additionalData:f(n)},s,f(t.subarray(8))));return{fileIndex:i,chunkIndex:a,plain:o}}function C(s,e=65536){return s===0?0:Math.ceil(s/e)}function H(s){try{let e=JSON.parse(s);if(!e||typeof e!="object"||typeof e.type!="string")return null;if(e.type==="manifest"){if(!Array.isArray(e.files)||e.files.length===0||e.files.length>1e3||!e.files.every(t=>typeof t?.name=="string"&&Number.isSafeInteger(t.size)&&t.size>=0)||typeof e.wrappedSecret!="string"||!Number.isSafeInteger(e.chunkSize))return null;if(e.auth!==void 0){let t=e.auth;if(!t||typeof t.salt!="string"||typeof t.nonce!="string"||!Number.isSafeInteger(t.iter)||t.iter<1e5||t.iter>5e6)return null}}if(e.type==="range"){let{index:t,from:n,to:r}=e;if(![t,n,r].every(i=>Number.isSafeInteger(i)&&i>=0)||r<n||r-n>=64)return null}if("device"in e&&e.device!==void 0){if(typeof e.device!="string")return null;e.device=e.device.replace(/[\u0000-\u001f\u007f]/g,"").slice(0,80)}return e}catch{return null}}function he(s){return String(s||"").replace(/[\u0000-\u001f\u007f]/g,"").replace(/[\\/:*?"<>|]/g,"_").replace(/^\.+/,"").trim().slice(0,200)||"file"}function je(s,e,t=qe){let n=he(e),r=Ye(n),i=r?n.slice(0,-r.length):n,a=fe(s,n);for(let o=1;t(a);o++)a=fe(s,`${i} (${o})${r}`);return a}var R=class{constructor(e){this.dir=e;Ge(e,{recursive:!0})}kind="fs-dir";written=[];stream=null;path="";async open(e,t){this.path=je(this.dir,Je(t.name)),this.stream=We(this.path,{flags:"wx"}),await new Promise((n,r)=>this.stream.once("open",()=>n()).once("error",r))}async write(e){let t=this.stream;t.write(e)||await new Promise(n=>t.once("drain",()=>n()))}async close(){let e=this.stream;e&&(await new Promise((t,n)=>e.end(r=>r?n(r):t())),this.written.push(this.path),this.stream=null)}async abort(){let e=this.stream;if(this.stream=null,e){e.destroy();try{ze(this.path,{force:!0})}catch{}}}};function W(s){let e=String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);if(!e)throw new Error(`invalid duration: ${s} (examples: 90s, 15m, 1h, 2d)`);let t=Number(e[1]),n=(e[2]??"s").toLowerCase(),r=t*{ms:1,s:1e3,m:6e4,h:36e5,d:864e5}[n];if(!(r>=1e3)||r>7*864e5)throw new Error(`duration out of range (1s..7d): ${s}`);return Math.round(r)}function me(s,e){if(!e)return s;let{link:t,code:n,...r}=s;return r}function E(s){let e=["B","KB","MB","GB","TB"],t=0,n=s;for(;n>=1024&&t<e.length-1;)n/=1024,t++;return`${t?n.toFixed(1):n} ${e[t]}`}var q="https://filenymous.eu/app/",G="wss://www.opendpe.net/filenymous-signal/";import{platform as ye}from"node:os";function I(){return`Filenymous CLI \xB7 ${{linux:"Linux",darwin:"macOS",win32:"Windows"}[ye()]??ye()}`}var Ve="wss://www.opendpe.net/filenymous-signal/",ge=[{urls:"stun:stun.l.google.com:19302"}];function ve(s){let e=import.meta.env?.[s];return e&&e.trim()?e.trim():void 0}function Y(){let s=globalThis.__FILENYMOUS_SIGNAL_URL__;if(s)return s;let e=ve("VITE_P2P_SIGNAL_URL");if(e)return e;let t=typeof location<"u"?location.hostname:"";return t==="localhost"||t==="127.0.0.1"?"ws://127.0.0.1:8789":Ve}function Ze(s=Y()){return s.replace(/^ws(s?):\/\//,"http$1://").replace(/\/+$/,"")}var z=null,we=0;async function J(){let s=ve("VITE_P2P_ICE_SERVERS");if(s)try{let e=JSON.parse(s);if(Array.isArray(e))return e}catch{}return(!z||Date.now()-we>30*6e4)&&(we=Date.now(),z=(async()=>{try{let e=new AbortController,t=setTimeout(()=>e.abort(),4e3),n=await fetch(`${Ze()}/ice`,{signal:e.signal,cache:"no-store"});if(clearTimeout(t),!n.ok)throw new Error(String(n.status));let r=await n.json();return r.iceServers?.length?r.iceServers:ge}catch{return ge}})()),z}var A=class s{constructor(e,t,n,r=Y()){this.code=e;this.role=t;this.handlers=n;this.url=r}ws=null;closed=!1;attempt=0;queue=[];connect(){return new Promise((e,t)=>{let n=!1,r=new WebSocket(this.url);this.ws=r;let i=setTimeout(()=>{if(!n){n=!0;try{r.close()}catch{}t(new Error("signal-timeout"))}},1e4);r.onopen=()=>r.send(JSON.stringify({type:"join",code:this.code,role:this.role})),r.onmessage=a=>{let o;try{o=JSON.parse(String(a.data))}catch{return}switch(o.type){case"joined":for(this.attempt=0,n||(n=!0,clearTimeout(i),e());this.queue.length;)r.send(this.queue.shift());break;case"peer-joined":this.handlers.onPeerJoined?.();break;case"peer-left":this.handlers.onPeerLeft?.();break;case"signal":o.payload&&this.handlers.onSignal(o.payload);break;case"error":n?this.handlers.onError?.(o.error||"signal-error"):(n=!0,clearTimeout(i),this.closed=!0,r.close(),t(new Error(o.error||"signal-error")));break;default:break}},r.onerror=()=>{n||(n=!0,clearTimeout(i),t(new Error("signal-unreachable")))},r.onclose=()=>{if(this.ws!==r||this.closed||!n)return;this.handlers.onReconnecting?.();let a=Math.min(15e3,500*2**this.attempt++);setTimeout(()=>{this.closed||this.connect().catch(o=>this.handlers.onError?.(String(o.message||o)))},a)}})}clone(){return new s(this.code,this.role,this.handlers,this.url)}send(e){let t=JSON.stringify({type:"signal",code:this.code,payload:e});this.ws?.readyState===WebSocket.OPEN?this.ws.send(t):this.queue.push(t)}close(){this.closed=!0;try{this.ws?.readyState===WebSocket.OPEN&&this.ws.send(JSON.stringify({type:"leave"})),this.ws?.close(1e3,"done")}catch{}}};function j(s){let e={candidate:s.candidate,sdpMid:s.sdpMid,sdpMLineIndex:s.sdpMLineIndex};return s.usernameFragment&&(e.usernameFragment=s.usernameFragment),e}function Se(s,e,t,n=65536){let r=Math.max(0,t-1),i=Math.min(Math.max(0,s),r),a=Math.min(Math.max(i,e),r);return{from:Math.floor(i/n),to:Math.floor(a/n)}}var Qe=256,et=32;function tt(s,e,t){let n=[],r=-1;for(let i=s;i<=e+1;i++){let a=i<=e&&!t(i);a&&r<0&&(r=i),r>=0&&(!a||i-r===64)&&(n.push([r,i-1]),r=a?i:-1)}return n}var M=class{constructor(e,t,n,r){this.key=e;this.files=t;this.send=n;this.onBytes=r}cache=new Map;waiters=new Map;requested=new Set;received=0;get receivedBytes(){return this.received}async onFrame(e){let{fileIndex:t,chunkIndex:n,plain:r}=await _(this.key,e),i=`${t}:${n}`;for(this.requested.delete(i),this.received+=r.byteLength,this.onBytes?.(this.received),this.cache.delete(i),this.cache.set(i,r);this.cache.size>Qe;)this.cache.delete(this.cache.keys().next().value);let a=this.waiters.get(i);if(a){this.waiters.delete(i);for(let o of a)o(r)}}request(e,t,n,r=!1){let i=a=>this.cache.has(`${e}:${a}`)||this.requested.has(`${e}:${a}`);for(let[a,o]of tt(t,n,i)){for(let c=a;c<=o;c++)this.requested.add(`${e}:${c}`);this.send({type:"range",index:e,from:a,to:o,...r?{prefetch:!0}:{}})}}chunk(e,t){let n=`${e}:${t}`,r=this.cache.get(n);return r?(this.cache.delete(n),this.cache.set(n,r),Promise.resolve(r)):new Promise(i=>{let a=this.waiters.get(n)??[];a.push(i),this.waiters.set(n,a)})}async read(e,t,n){let r=this.files[e];if(!r)throw new Error("bad-file");if(r.size===0)return new Uint8Array(0);let{from:i,to:a}=Se(t,n,r.size);this.request(e,i,a);let o=Math.ceil(r.size/65536)-1;a<o&&this.request(e,a+1,Math.min(o,a+et),!0);let c=await Promise.all(Array.from({length:a-i+1},(v,d)=>this.chunk(e,i+d))),l=new Uint8Array(Math.min(n,r.size-1)-t+1),h=0,y=t-i*65536;for(let v of c){let d=v.subarray(y,y+(l.length-h));l.set(d,h),h+=d.length,y=0}return l}};var nt=4*1024*1024,Ae=1*1024*1024,Te=45e3,st=3e4,it=300;function w(s,e){s.readyState==="open"&&s.send(JSON.stringify(e))}function be(s){return s.bufferedAmount<=nt?Promise.resolve():new Promise((e,t)=>{let n=setInterval(()=>{s.readyState!=="open"?i():s.bufferedAmount<=Ae&&r()},200),r=()=>{a(),e()},i=()=>{a(),t(new Error("connection-lost"))},a=()=>{clearInterval(n),s.removeEventListener("bufferedamountlow",r),s.removeEventListener("close",i)};s.addEventListener("bufferedamountlow",r),s.addEventListener("close",i)})}async function ke(s,e,t){if(e.kind==="ice"){if(!s.remoteDescription){t.push(e.candidate);return}try{await s.addIceCandidate(e.candidate)}catch{}}}var L=class{constructor(e,t,n={}){this.files=e;this.onState=t;this.streamable=!!n.stream,n.password&&(this.pwSalt=O(16),this.pwKeys=$(n.password,this.pwSalt)),this.code=n.code??re(),this.secret=n.secret??ne(),this.message=n.message,this.state={status:"starting",code:this.code,secret:this.secret,sentBytes:0,confirmedBytes:0,totalBytes:e.reduce((r,i)=>r+i.size,0),speed:0,fileStates:e.map(()=>"pending"),downloads:0,log:[],protected:!!n.password,authFailures:0},this.signal=new A(this.code,"sender",{onSignal:r=>void this.onSignal(r),onPeerJoined:()=>void this.offer(),onPeerLeft:()=>{this.attempt&&!this.attempt.open&&this.dropAttempt(this.idleStatus())},onReconnecting:()=>{this.attempt?.open||this.update({status:"reconnecting"})},onError:r=>this.update({status:"error",error:r})},n.signalUrl)}code;secret;message;pwKeys;pwSalt;authFailures=0;signal;attempt=null;pendingIce=[];state;stopped=!1;nextId=1;offerGen=0;connectTimer;streamable;fileKey;update(e){this.state={...this.state,...e},this.onState(this.state)}getState(){return this.state}idleStatus(){if(this.pwKeys&&this.authFailures>=5)return"failed";let e=this.state.log[0];return e?e.result==="complete"?"complete":"failed":"waiting"}async start(){this.update({status:"starting"}),await this.signal.connect(),this.update({status:"waiting"}),await this.offer()}dropAttempt(e){clearTimeout(this.connectTimer);let t=this.attempt;if(this.attempt=null,this.pendingIce=[],t){t.finished=!0;try{t.pc.close()}catch{}}this.stopped||this.update({status:e})}record(e,t,n){if(e.finished)return;e.finished=!0;let r={id:e.id,startedAt:e.startedAt,endedAt:Date.now(),bytes:t==="complete"?this.state.totalBytes:this.state.confirmedBytes,totalBytes:this.state.totalBytes,result:t,...n?{reason:n}:{}};this.update({log:[r,...this.state.log].slice(0,50),downloads:this.state.downloads+(t==="complete"?1:0),status:t==="complete"?"complete":"failed",error:n,...t==="complete"?{confirmedBytes:this.state.totalBytes,fileStates:this.files.map(()=>"delivered")}:{fileStates:this.state.fileStates.map(i=>i==="delivered"?i:"failed")}})}fail(e,t){this.attempt===e&&(e.started&&!e.finished&&this.record(e,"failed",t),this.dropAttempt(this.idleStatus()),e.started||this.update({error:t}))}async offer(){if(this.stopped||this.attempt?.open&&!this.attempt.finished)return;let e=++this.offerGen;this.dropAttempt(this.idleStatus());let t=await J();if(e!==this.offerGen||this.stopped)return;let n=new RTCPeerConnection({iceServers:t}),r=n.createDataChannel("filenymous-p2p-v2",{ordered:!0});r.binaryType="arraybuffer",r.bufferedAmountLowThreshold=Ae;let i={id:this.nextId++,pc:n,channel:r,open:!1,started:!1,authed:!1,nonce:"",finished:!1,startedAt:0,lastConfirm:{at:0,bytes:0}};this.attempt=i,n.onicecandidate=o=>{o.candidate&&this.attempt===i&&this.signal.send({kind:"ice",candidate:j(o.candidate)})},n.onconnectionstatechange=()=>{this.attempt===i&&n.connectionState==="failed"&&this.fail(i,i.open?"connection-lost":"ice-failed")},r.onopen=()=>{this.attempt===i&&(i.open=!0,clearTimeout(this.connectTimer),this.serve(i))},r.onclose=()=>{this.attempt===i&&!i.finished&&this.fail(i,i.started?"connection-lost":"recipient-left")};let a=await n.createOffer();await n.setLocalDescription(a),this.attempt===i&&this.signal.send({kind:"offer",sdp:a.sdp??""})}async onSignal(e){let t=this.attempt;if(!t)return;let n=t.pc;if(e.kind==="answer"&&n.signalingState==="have-local-offer"){this.update({status:"connecting",error:void 0}),clearTimeout(this.connectTimer),this.connectTimer=setTimeout(()=>{this.attempt===t&&!t.open&&this.fail(t,"ice-timeout")},Te);try{await n.setRemoteDescription({type:"answer",sdp:e.sdp})}catch{this.attempt===t&&this.offer();return}for(let r of this.pendingIce.splice(0))await n.addIceCandidate(r).catch(()=>{})}else e.kind==="ice"&&await ke(n,e,this.pendingIce)}async serve(e){let{channel:t}=e;if(t.onmessage=n=>{if(typeof n.data!="string"||this.attempt!==e)return;let r=H(n.data);if(r)switch(r.type){case"start":if(this.pwKeys&&!e.authed){this.fail(e,"auth-required");break}this.stream(e);break;case"auth":this.checkAuth(e,r.proof);break;case"range":if(!this.streamable)break;if(this.pwKeys&&!e.authed){this.fail(e,"auth-required");break}this.queueRange(e,r.index,r.from,r.to,r.prefetch===!0);break;case"progress":{let i=Date.now(),a=Math.min(this.state.totalBytes,Math.max(0,Number(r.bytes)||0)),o=(i-e.lastConfirm.at)/1e3,c=o>0?(a-e.lastConfirm.bytes)/o:0;e.lastConfirm={at:i,bytes:a},this.update({confirmedBytes:a,speed:this.state.speed?this.state.speed*.7+c*.3:c});break}case"file-ack":{let i=[...this.state.fileStates];r.index>=0&&r.index<i.length&&(i[r.index]="delivered"),this.update({fileStates:i});break}case"ack":this.record(e,"complete"),setTimeout(()=>{this.attempt===e&&this.dropAttempt("complete")},1500);break;case"cancel":this.fail(e,r.reason||"cancelled");break;case"hello":this.update({peerDevice:r.device});break;default:break}},this.pwKeys){if(this.authFailures>=5){w(t,{type:"cancel",reason:"too-many-attempts"}),this.fail(e,"too-many-attempts");return}e.nonce=O(16),w(t,{type:"manifest",v:3,chunkSize:65536,files:[{name:"locked",size:0,mime:""}],wrappedSecret:"",auth:{salt:this.pwSalt,iter:31e4,nonce:e.nonce},device:I()}),this.attempt===e&&this.update({status:"connected",error:void 0});return}await this.sendManifest(e)}async checkAuth(e,t){if(!this.pwKeys||e.authed||e.finished)return;let n=typeof t=="string"&&await ce(await this.pwKeys,this.code,e.nonce,t);if(this.attempt!==e)return;if(n){e.authed=!0,w(e.channel,{type:"auth-ok"}),await this.sendManifest(e);return}this.authFailures+=1;let r=Math.max(0,5-this.authFailures);this.update({authFailures:this.authFailures}),w(e.channel,{type:"auth-fail",remaining:r}),r===0&&(w(e.channel,{type:"cancel",reason:"too-many-attempts"}),this.fail(e,"too-many-attempts"),setTimeout(()=>{this.stopped=!0,this.signal.close(),this.update({status:"failed",error:"too-many-attempts"})},1e3))}async sendManifest(e){let{channel:t}=e;w(t,{type:"manifest",v:2,chunkSize:65536,files:this.files.map(n=>({name:n.name,size:n.size,mime:n.type||"application/octet-stream"})),wrappedSecret:await de(this.code,this.secret),...this.message?{message:this.message}:{},device:I(),...this.pwKeys?{protected:!0}:{},...this.streamable?{streamable:!0}:{}}),this.attempt===e&&this.update({status:"connected",error:void 0})}key(){return this.fileKey??=(async()=>x(this.secret,this.pwKeys?(await this.pwKeys).mix:void 0))(),this.fileKey}queueRange(e,t,n,r,i){let a=this.files[t];if(!a)return;let o=C(a.size)-1,c=i?e.prefetch??=[]:e.demand??=[];for(let l=n;l<=Math.min(r,o);l++)c.push([t,l]);e.pumping||this.pumpRanges(e).catch(l=>this.fail(e,String(l.message||l)))}async pumpRanges(e){e.pumping=!0;try{let t=await this.key(),n=Number(globalThis.__FILENYMOUS_TEST_CHUNK_DELAY_MS__)||0,r=new Set;for(;;){let i=e.demand?.shift()??e.prefetch?.shift();if(!i)break;let[a,o]=i,c=`${a}:${o}`;if(r.has(c))continue;if(r.add(c),r.size>4096&&r.clear(),this.attempt!==e||e.channel.readyState!=="open")throw new Error("connection-lost");let l=new Uint8Array(await this.files[a].slice(o*65536,(o+1)*65536).arrayBuffer());await be(e.channel),n&&await new Promise(h=>setTimeout(h,n)),e.channel.send(await K(t,a,o,l)),this.update({status:"streaming",sentBytes:this.state.sentBytes+l.byteLength,error:void 0})}}finally{e.pumping=!1}}async stream(e){if(e.started)return;e.started=!0,e.startedAt=Date.now(),e.lastConfirm={at:e.startedAt,bytes:0};let{channel:t}=e;this.update({status:"transferring",sentBytes:0,confirmedBytes:0,speed:0,fileStates:this.files.map(()=>"pending"),error:void 0});try{let n=await x(this.secret,this.pwKeys?(await this.pwKeys).mix:void 0),r=0,i=0;for(let a=0;a<this.files.length;a++){let o=this.files[a],c=[...this.state.fileStates];c[a]="sending",this.update({fileStates:c});let l=C(o.size);for(let h=0;h<l;h++){if(this.attempt!==e||t.readyState!=="open")throw new Error("connection-lost");let y=new Uint8Array(await o.slice(h*65536,(h+1)*65536).arrayBuffer());await be(t),t.send(await K(n,a,h,y)),r+=y.byteLength,(Date.now()-i>150||r===this.state.totalBytes)&&(i=Date.now(),this.update({sentBytes:r}))}w(t,{type:"file-end",index:a,chunks:l})}w(t,{type:"done"}),this.update({sentBytes:r})}catch(n){this.fail(e,String(n.message||n))}}stop(){this.stopped=!0;let e=this.attempt;if(e?.started&&!e.finished&&this.record(e,"failed","sender-stopped"),clearTimeout(this.connectTimer),e){try{w(e.channel,{type:"cancel",reason:"sender-stopped"})}catch{}try{e.pc.close()}catch{}}this.attempt=null,this.signal.close(),this.update({status:"stopped"})}},B=class{constructor(e,t,n,r,i=st){this.code=e;this.secret=t;this.onState=n;this.stallTimeoutMs=i;this.signal=new A(e,"receiver",{onSignal:a=>void this.onSignal(a),onPeerLeft:()=>{!this.channel&&this.state.status!=="complete"&&this.state.status!=="error"&&this.update({status:"waiting-sender"})},onError:a=>{this.channel||this.update({status:"error",error:a})}},r)}signal;pc=null;channel=null;pendingIce=[];key=null;state={status:"connecting",files:[],receivedBytes:0,totalBytes:0,speed:0};sink=null;queue=Promise.resolve();expected={file:0,chunk:0};connectTimer;stallTimer;lastData=0;lastProgressSent=0;speedRef={at:0,bytes:0};closed=!1;offerGen=0;challenge=null;pwMix=null;streamReader=null;update(e){this.state={...this.state,...e},this.onState(this.state)}fail(e){if(!(this.state.status==="complete"||this.state.status==="error")){if(clearInterval(this.stallTimer),this.sink?.abort(),this.channel?.readyState==="open")try{this.channel.send(JSON.stringify({type:"cancel",reason:e}))}catch{}this.update({status:"error",error:e})}}async start(){for(let e=0;!this.closed;e++)try{await this.signal.connect(),this.pc||this.update({status:"waiting-sender",error:void 0});return}catch(t){let n=String(t.message||t);if(n!=="role-taken"||e>200){this.update({status:"error",error:n});return}this.update({status:"waiting-sender",error:n}),await new Promise(r=>setTimeout(r,3e3)),this.signal=this.signal.clone()}}async onSignal(e){if(e.kind==="offer"){if(this.channel?.readyState==="open"||this.state.status==="receiving"||this.state.status==="complete")return;let t=++this.offerGen;try{this.pc?.close()}catch{}this.pc=null,this.pendingIce=[],this.channel=null;let n=await J();if(t!==this.offerGen||this.closed)return;let r=new RTCPeerConnection({iceServers:n});this.pc=r,this.update({status:"negotiating",error:void 0}),clearTimeout(this.connectTimer),this.connectTimer=setTimeout(()=>{this.pc===r&&this.state.status==="negotiating"&&this.update({status:"error",error:"ice-timeout"})},Te),r.onicecandidate=a=>{a.candidate&&this.pc===r&&this.signal.send({kind:"ice",candidate:j(a.candidate)})},r.onconnectionstatechange=()=>{this.pc===r&&r.connectionState==="failed"&&this.fail(this.channel?"connection-lost":"ice-failed")},r.ondatachannel=a=>this.attach(a.channel),await r.setRemoteDescription({type:"offer",sdp:e.sdp});for(let a of this.pendingIce.splice(0))await r.addIceCandidate(a).catch(()=>{});let i=await r.createAnswer();if(await r.setLocalDescription(i),t!==this.offerGen)return;this.signal.send({kind:"answer",sdp:i.sdp??""})}else e.kind==="ice"&&(this.pc?await ke(this.pc,e,this.pendingIce):this.pendingIce.push(e.candidate))}attach(e){this.channel=e,e.binaryType="arraybuffer",e.onmessage=t=>{let n=t.data;this.lastData=Date.now(),this.queue=this.queue.then(()=>this.handle(n)).catch(r=>this.fail(String(r.message||r)))},e.onclose=()=>{["receiving","ready","password","streaming"].includes(this.state.status)&&this.fail("sender-disconnected")}}sendProgress(e=!1){let t=Date.now();if(!e&&t-this.lastProgressSent<it)return;this.lastProgressSent=t;let n=(t-this.speedRef.at)/1e3;if(n>=.5){let r=(this.state.receivedBytes-this.speedRef.bytes)/n;this.speedRef={at:t,bytes:this.state.receivedBytes},this.update({speed:this.state.speed?this.state.speed*.7+r*.3:r})}this.channel?.readyState==="open"&&this.channel.send(JSON.stringify({type:"progress",bytes:this.state.receivedBytes}))}async handle(e){if(this.state.status==="error")return;if(typeof e=="string"){let i=H(e);if(!i)return;if(i.type==="manifest"&&i.v===3&&i.auth)clearTimeout(this.connectTimer),this.challenge=i.auth,this.pwMix=null,this.update({status:"password",peerDevice:i.device,authChecking:!1,authRemaining:5});else if(i.type==="auth-fail")this.pwMix=null,this.update({authChecking:!1,authError:!0,authRemaining:Math.max(0,Number(i.remaining)||0)});else if(i.type==="auth-ok")this.update({authError:!1});else if(i.type==="manifest"){if(clearTimeout(this.connectTimer),i.protected&&!this.pwMix)throw new Error("auth-required");if(i.v!==2||i.chunkSize!==65536)throw new Error("unsupported-protocol");let a=this.secret??await pe(this.code,i.wrappedSecret);this.key=await x(a,i.protected?this.pwMix:void 0),this.update({status:"ready",authChecking:!1,authError:!1,files:i.files,message:i.message,peerDevice:i.device,streamable:i.streamable===!0,totalBytes:i.files.reduce((o,c)=>o+c.size,0)}),this.channel?.send(JSON.stringify({type:"hello",device:I()}))}else if(i.type==="file-end"){let a=this.state.files[i.index];if(!a||i.index!==this.expected.file||i.chunks!==C(a.size)||this.expected.chunk!==i.chunks)throw new Error("integrity-error");i.chunks===0&&await this.sink.open(i.index,a),await this.sink.close(),this.expected={file:i.index+1,chunk:0},this.sendProgress(!0),this.channel?.send(JSON.stringify({type:"file-ack",index:i.index}))}else if(i.type==="done"){if(this.expected.file!==this.state.files.length)throw new Error("integrity-error");clearInterval(this.stallTimer),this.channel?.send(JSON.stringify({type:"ack",bytes:this.state.receivedBytes})),this.update({status:"complete",receivedBytes:this.state.totalBytes}),setTimeout(()=>this.close(),5e3)}else i.type==="cancel"&&this.fail(i.reason==="sender-stopped"?"sender-stopped":i.reason||"cancelled");return}if(this.streamReader){await this.streamReader.onFrame(e).catch(()=>{throw new Error("decrypt-error")});return}if(!this.key||!this.sink)throw new Error("unexpected-data");let{fileIndex:t,chunkIndex:n,plain:r}=await _(this.key,e).catch(()=>{throw new Error("decrypt-error")});if(t!==this.expected.file||n!==this.expected.chunk)throw new Error("integrity-error");n===0&&await this.sink.open(t,this.state.files[t]),await this.sink.write(r),this.expected.chunk+=1,this.state={...this.state,receivedBytes:this.state.receivedBytes+r.byteLength},this.onState(this.state),this.sendProgress()}async submitPassword(e){if(this.state.status!=="password"||!this.challenge||this.state.authChecking)return;if(!this.channel||this.channel.readyState!=="open"){this.fail("sender-disconnected");return}this.update({authChecking:!0,authError:!1});let t=this.challenge,n=await $(e,t.salt,t.iter),r=await oe(n,this.code,t.nonce);this.pwMix=n.mix,this.channel.send(JSON.stringify({type:"auth",proof:r}))}startStreaming(){if(this.streamReader)return this.streamReader;if(this.state.status!=="ready"||!this.state.streamable||!this.key)throw new Error("not-streamable");if(!this.channel||this.channel.readyState!=="open")throw new Error("sender-disconnected");let e=this.channel;return this.streamReader=new M(this.key,this.state.files,t=>e.readyState==="open"&&e.send(JSON.stringify(t)),t=>this.update({receivedBytes:t})),this.update({status:"streaming",receivedBytes:0}),this.streamReader}download(e){if(this.state.status!=="ready")throw new Error("not-ready");if(!this.channel||this.channel.readyState!=="open"){this.fail("sender-disconnected");return}this.sink=e,this.expected={file:0,chunk:0},this.lastData=Date.now(),this.speedRef={at:Date.now(),bytes:0},this.update({status:"receiving",receivedBytes:0,speed:0}),this.channel.send(JSON.stringify({type:"start"})),clearInterval(this.stallTimer),this.stallTimer=setInterval(()=>{if(this.state.status!=="receiving")return clearInterval(this.stallTimer);Date.now()-this.lastData>this.stallTimeoutMs&&this.fail("stalled")},1e3)}close(){this.closed=!0,clearTimeout(this.connectTimer),clearInterval(this.stallTimer),this.signal.close();try{this.pc?.close()}catch{}}};var xe=`filenymous 0.2.0 \u2014 end-to-end encrypted P2P file sharing (no server storage)
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
filenymous upload <file...> [options] share files; stays online until done
|
|
7
|
+
filenymous receive <link|code> [options] download a share into a directory
|
|
8
|
+
|
|
9
|
+
upload options:
|
|
10
|
+
--ttl <dur> how long the share stays online (default 1h; e.g. 90s, 15m, 2d)
|
|
11
|
+
--max-downloads <n> exit after n completed downloads (default 1)
|
|
12
|
+
--password <pw> require a password (prefer env FILENYMOUS_PASSWORD)
|
|
13
|
+
--webhook <url> POST JSON events (share.created, download.completed, share.expired)
|
|
14
|
+
--webhook-no-link omit link and code from webhook payloads
|
|
15
|
+
--stream let web recipients play audio/video while it downloads
|
|
16
|
+
--allow-empty exit 0 even if the TTL expires with zero downloads (default exit 2)
|
|
17
|
+
--json machine-readable JSON lines on stdout
|
|
18
|
+
--app-url <url> web app base for links (default ${q})
|
|
19
|
+
|
|
20
|
+
receive options:
|
|
21
|
+
-o, --output <dir> destination directory (default .)
|
|
22
|
+
--password <pw> password for protected shares (or env FILENYMOUS_PASSWORD)
|
|
23
|
+
--timeout <dur> give up if nothing completes in time (default 30m)
|
|
24
|
+
--json machine-readable JSON lines on stdout
|
|
25
|
+
|
|
26
|
+
common:
|
|
27
|
+
--signal <wss-url> signaling relay (default ${G})
|
|
28
|
+
-q, --quiet no progress on stderr
|
|
29
|
+
-h, --help, -v, --version
|
|
30
|
+
`,{values:p,positionals:ct}=at({allowPositionals:!0,options:{ttl:{type:"string"},"max-downloads":{type:"string"},password:{type:"string"},webhook:{type:"string"},"webhook-no-link":{type:"boolean"},"allow-empty":{type:"boolean"},stream:{type:"boolean"},json:{type:"boolean"},"app-url":{type:"string"},signal:{type:"string"},output:{type:"string",short:"o"},timeout:{type:"string"},quiet:{type:"boolean",short:"q"},help:{type:"boolean",short:"h"},version:{type:"boolean",short:"v"}}}),m=s=>process.stderr.write(`${s}
|
|
31
|
+
`),U=s=>p.json&&process.stdout.write(`${JSON.stringify(s)}
|
|
32
|
+
`),P=0;function Ce(s,e,t){if(p.quiet||!process.stderr.isTTY&&Date.now()-P<2e3||Date.now()-P<250&&e<t)return;P=Date.now();let n=t?Math.floor(e/t*100):100,r=`${s} ${n}% (${E(e)} / ${E(t)})`;process.stderr.isTTY?process.stderr.write(`\r\x1B[2K${r}`):m(r)}var F=()=>process.stderr.isTTY&&!p.quiet&&P&&process.stderr.write(`
|
|
33
|
+
`);async function lt(s,e){try{let t=new AbortController,n=setTimeout(()=>t.abort(),5e3),r=await fetch(s,{method:"POST",headers:{"content-type":"application/json","user-agent":"filenymous-cli/0.2.0"},body:JSON.stringify(e),signal:t.signal});clearTimeout(n),r.ok||m(`warning: webhook answered HTTP ${r.status}`)}catch(t){m(`warning: webhook failed: ${t.message}`)}}async function dt(s){if(!s.length)throw new Error("upload: missing file (try: filenymous upload app.zip --ttl 1h)");let e=W(p.ttl??"1h"),t=Number(p["max-downloads"]??1);if(!Number.isInteger(t)||t<1)throw new Error("--max-downloads must be a positive integer");let n=p.password??process.env.FILENYMOUS_PASSWORD??void 0,r=[];for(let u of s)r.push(await ee(_e(u)));let i=r.reduce((u,k)=>u+k.size,0),a=r.length===1?r[0].name:r.map(u=>u.name).join(", "),o=new Date(Date.now()+e).toISOString(),c=0,l=()=>{},h=new Promise(u=>l=u),y=new L(r,u=>{u.status==="transferring"&&Ce("sending",u.confirmedBytes,u.totalBytes),u.downloads>c?(c=u.downloads,F(),P=0,p.quiet||m(`\u2713 download ${c}/${t} completed${u.peerDevice?` (${u.peerDevice})`:""}`),d("download.completed").then(()=>c>=t&&setTimeout(()=>l(0),1600))):u.status==="failed"&&u.error==="too-many-attempts"?(m("\u2717 too many wrong passwords: share locked"),d("share.failed",u.error).then(()=>l(1))):u.status==="failed"&&u.error&&!p.quiet&&(F(),P=0,m(`\u2026 transfer interrupted (${u.error}); still waiting for a recipient`))},{password:n,stream:!!p.stream}),v=se(p["app-url"]??process.env.FILENYMOUS_APP_URL??q,y.code,y.secret);async function d(u,k){let X={event:u,link:v,code:y.code,file:a,size:i,expiresAt:o,downloads:c,maxDownloads:t,protected:!!n,...k?{error:k}:{}};U(X),p.webhook&&await lt(p.webhook,me(X,!!p["webhook-no-link"]))}await y.start(),p.json||process.stdout.write(`${v}
|
|
34
|
+
`),p.quiet||(m(`Filenymous \u2014 ${a} (${E(i)})${n?", password-protected":""}`),m(`code: ${y.code} expires: ${o} downloads: 0/${t}`),m("Keep this process running: files go directly to the recipient, nothing is stored on a server.")),await d("share.created");let T=setTimeout(()=>{d("share.expired").then(()=>l(c>0||p["allow-empty"]?0:2))},e),V=()=>l(130);process.once("SIGINT",V),process.once("SIGTERM",V);let Z=await h;clearTimeout(T),Z===2&&!p.quiet&&m("share expired with no download"),y.stop();for(let u of r)await u.close();return Z}function pt(s){let e=ot({input:process.stdin,output:process.stderr,terminal:!0});return new Promise(t=>e.question(s,n=>{e.close(),t(n)}))}async function ut(s){let e=ie(s??"");if(!e)throw new Error("receive: give a Filenymous link (\u2026#p2p:123-456-ABC/secret) or a code");let t=_e(p.output??"."),n=W(p.timeout??"30m"),r=p.password??process.env.FILENYMOUS_PASSWORD??void 0,i=new R(t),a=()=>{},o=new Promise(d=>a=d),c=!1,l=!1,h=new B(e.code,e.secret,d=>{switch(d.status){case"waiting-sender":!c&&!p.quiet&&m(`waiting for the sender of ${e.code}\u2026`),c=!0;break;case"password":if(d.authChecking)break;if(d.authError&&l&&(p.password||process.env.FILENYMOUS_PASSWORD||!process.stdin.isTTY)){m(`\u2717 wrong password (${d.authRemaining??0} attempts left)`),a(1);break}(async()=>{if(!r||d.authError){if(!process.stdin.isTTY)return m("\u2717 this share needs a password (--password or FILENYMOUS_PASSWORD)"),a(1);r=await pt(d.authError?`wrong password (${d.authRemaining} left), try again: `:"password: ")}l=!0,await h.submitPassword(r)})();break;case"ready":p.quiet||m(`receiving ${d.files.length} file(s), ${E(d.totalBytes)}${d.peerDevice?` from ${d.peerDevice}`:""} \u2192 ${t}`),h.download(i);break;case"receiving":Ce("receiving",d.receivedBytes,d.totalBytes);break;case"complete":F();for(let T of i.written)p.json?U({event:"file.received",path:T}):process.stdout.write(`${T}
|
|
35
|
+
`);U({event:"download.completed",files:i.written,size:d.totalBytes}),setTimeout(()=>a(0),5200);break;case"error":F(),m(`\u2717 ${d.error??"error"}`),U({event:"error",error:d.error}),a(1);break}}),y=setTimeout(()=>{m("\u2717 timeout"),a(1)},n);process.once("SIGINT",()=>a(130)),h.start();let v=await o;return clearTimeout(y),h.close(),v}async function ht(){if(p.version)return process.stdout.write(`0.2.0
|
|
36
|
+
`),0;let[s,...e]=ct;return p.help||!s?(process.stdout.write(xe),s||p.help?0:1):(Q(p.signal??process.env.FILENYMOUS_SIGNAL_URL??G),s==="upload"||s==="send"?dt(e):s==="receive"||s==="get"?ut(e[0]):(m(`unknown command: ${s}
|
|
37
|
+
`),process.stdout.write(xe),1))}ht().then(s=>process.exit(s),s=>{m(`\u2717 ${s.message||s}`),process.exit(1)});
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "filenymous",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Ultra-light end-to-end encrypted P2P file sharing from the terminal (WebRTC, no server storage). Compatible with https://filenymous.eu/app/",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"filenymous": "dist/filenymous.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.17"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"p2p",
|
|
19
|
+
"webrtc",
|
|
20
|
+
"file-transfer",
|
|
21
|
+
"e2ee",
|
|
22
|
+
"ci",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/Geoking2104/Filenymous.git",
|
|
28
|
+
"directory": "cli"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node build.mjs",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "npm run build && node --test test/"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"node-datachannel": "^0.33.4",
|
|
37
|
+
"ws": "^8.18.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.14.0",
|
|
41
|
+
"@types/ws": "^8.5.12",
|
|
42
|
+
"esbuild": "^0.24.0",
|
|
43
|
+
"typescript": "^5.6.3"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/Geoking2104/Filenymous/tree/main/cli#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Geoking2104/Filenymous/issues"
|
|
48
|
+
}
|
|
49
|
+
}
|