From 24709d582d14c5143af6115dc20b6867fcc7c594 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 16:45:07 +0200 Subject: [PATCH] Pithos: public links at navigate.st/sia for files in your own Sia account A share link from s3d only opened where s3d is reachable, which on a default setup is this computer. Pithos now makes a Sia shared-object URL for the file the way the Sia SDK does (the indexer's sealed object, its data key opened with a key derived from s3d's app key) and registers it at navigate.st, which streams the file from Sia hosts for anyone, with seeking, even with this computer off. A shared-object URL reads that one object and nothing else in the account. Files still waiting on this computer are uploaded first. A folder gets a page with its files and a player. Settings lists the links, each one can be switched off. sia-share is the navigate.st side, in Go on the Sia SDK: it keeps short ids, checks every URL against an allowed indexer, serves files sandboxed so a shared page cannot run script on navigate.st, and caps sign-ups per address and downloads per link per day. Host reads are paid by its own app on Silent Mode's indexer account. --- core/server.js | 62 +++ core/sia-share.js | 107 +++++ sia-share/deploy/README.md | 15 + sia-share/deploy/sia-share.example.json | 9 + sia-share/deploy/sia-share.service | 18 + sia-share/go.mod | 33 ++ sia-share/go.sum | 84 ++++ sia-share/main.go | 585 ++++++++++++++++++++++++ sia-share/main_test.go | 37 ++ test/sia-share.test.js | 38 ++ ui/app.js | 68 ++- 11 files changed, 1047 insertions(+), 9 deletions(-) create mode 100644 core/sia-share.js create mode 100644 sia-share/deploy/README.md create mode 100644 sia-share/deploy/sia-share.example.json create mode 100644 sia-share/deploy/sia-share.service create mode 100644 sia-share/go.mod create mode 100644 sia-share/go.sum create mode 100644 sia-share/main.go create mode 100644 sia-share/main_test.go create mode 100644 test/sia-share.test.js diff --git a/core/server.js b/core/server.js index 8855e9d..d571687 100644 --- a/core/server.js +++ b/core/server.js @@ -28,6 +28,7 @@ import { installHosted } from './hosted-routes.js'; import { accountInfo, readConnection, fingerprint } from './sia-account.js'; import { guessType } from './hosted.js'; import { accountSpace } from './space.js'; +import { createShareUrl } from './sia-share.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const UI_DIR = path.join(HERE, '..', 'ui'); @@ -466,6 +467,67 @@ export async function createPithos(opts = {}) { return { name, moved }; }); + // ---- public links: navigate.st/sia// ----------------------------------- + // A file in your own Sia account gets a Sia shared-object URL (it reads that + // one file and nothing else), registered at navigate.st, which streams it + // from Sia for anyone. Files still waiting on this computer go up first. + // The delete tokens stay here, beside s3d.yml, so links can be switched off. + const SIA_LINKS_API = process.env.PITHOS_SIA_LINKS_API || 'https://navigate.st/api/sia/links'; + const siaLinksFile = () => path.join(path.dirname(configFile), 'pithos-sia-links.json'); + const readSiaLinks = () => { try { return JSON.parse(fs.readFileSync(siaLinksFile(), 'utf8')); } catch { return []; } }; + const writeSiaLinks = (list) => fs.writeFileSync(siaLinksFile(), JSON.stringify(list, null, 2), { mode: 0o600 }); + async function shareUrlFor(bucket, key, until) { + const dir = cfg().directory; + try { return await createShareUrl(dir, bucket, key, until); } + catch (e) { + if (e.code !== 'pending') throw new HttpError(e.status || 502, e.message); + await admin.flush(cfg()); // send what is waiting, then try once more + try { return await createShareUrl(dir, bucket, key, until); } + catch (e2) { throw new HttpError(e2.status || 502, e2.code === 'pending' ? 'This file is still uploading to Sia. Try again in a minute.' : e2.message); } + } + } + route('POST', '/api/s3/:user/buckets/:bucket/sia-link', async (req, url, p) => { + if (hosted.isHosted()) throw new HttpError(409, 'Drives on Silent Mode share through Share… (navigate.st/s3).'); + const { key, prefix, days = 365 } = await jsonBody(req); + const until = Math.floor(Date.now() / 1000) + Math.min(3650, Math.max(1, Number(days) || 365)) * 86400; + let body; + if (prefix) { + if (!prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /'); + const s3 = await s3For(p.user); + const files = []; + let token; + do { + const page = await s3.listObjects(p.bucket, { prefix, token }); + for (const o of page.objects) if (o.key !== prefix && o.size > 0) files.push(o.key); + token = page.truncated ? page.nextToken : null; + } while (token && files.length < 1000); + if (!files.length) throw new HttpError(400, 'This folder has no files to share (files in sub-folders are not included).'); + const out = []; + for (const k of files) out.push({ name: k.slice(prefix.length), url: (await shareUrlFor(p.bucket, k, until)).url }); + body = { name: prefix.slice(0, -1).split('/').pop(), folder: true, files: out }; + } else { + if (!key || key.endsWith('/')) throw new HttpError(400, 'a file is required'); + body = { name: key.split('/').pop(), url: (await shareUrlFor(p.bucket, key, until)).url }; + } + const res = await fetch(SIA_LINKS_API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180_000) }); + const out = await res.json().catch(() => ({})); + if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`); + const list = readSiaLinks(); + list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: prefix || null, until, created: Date.now(), files: body.files ? body.files.length : 1 }); + writeSiaLinks(list); + return { id: out.id, url: out.url, until }; + }); + route('GET', '/api/sia-links', () => readSiaLinks().map(({ deleteToken, ...l }) => l)); + route('DELETE', '/api/sia-links/:id', async (req, url, p) => { + const list = readSiaLinks(); + const l = list.find((x) => x.id === p.id); + if (!l) throw new HttpError(404, 'no such link'); + const res = await fetch(`${SIA_LINKS_API}/${encodeURIComponent(l.id)}`, { method: 'DELETE', headers: { 'x-delete-token': l.deleteToken }, signal: AbortSignal.timeout(30_000) }); + if (!res.ok && res.status !== 404) throw new HttpError(502, `navigate.st answered ${res.status}`); + writeSiaLinks(list.filter((x) => x.id !== p.id)); + return { ok: true }; + }); + // ---- free space on the Sia account ---------------------------------------- // From the indexer (signed with s3d's app key). Cached for a minute. let spaceCache = { at: 0, value: null }; diff --git a/core/sia-share.js b/core/sia-share.js new file mode 100644 index 0000000..a2e0e4e --- /dev/null +++ b/core/sia-share.js @@ -0,0 +1,107 @@ +// Sia share links for files in your own Sia account. A shared object URL lets +// anyone read ONE object (its data key travels in the URL fragment) and +// nothing else in the account. It is made the way the Sia SDK makes it +// (siastorage CreateSharedObjectURL, indexd api/app/client.go): +// 1. s3d's database maps / to the object's Sia id; +// 2. the indexer returns the sealed object (signed GET /objects/); +// 3. its data key is opened with a key derived from s3d's app key: +// XChaCha20-Poly1305 under HKDF-BLAKE2b-256(appKey, salt = id, "dataKey"); +// 4. the link is a signed GET /objects//shared, valid until a date, +// with #encryption_key= (padded URL-safe base64). +// The app key is read from s3d's database for this and never leaves. +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { blake2b256 } from './blake2b.js'; +import { readConnection, signRequest } from './sia-account.js'; + +// ---- HMAC / HKDF over BLAKE2b-256 (block size 128) -------------------------------- +export function hmacBlake2b256(key, data) { + let k = Buffer.from(key); + if (k.length > 128) k = blake2b256(k); + const block = Buffer.alloc(128); + k.copy(block); + const ipad = Buffer.from(block.map((b) => b ^ 0x36)); + const opad = Buffer.from(block.map((b) => b ^ 0x5c)); + return blake2b256(Buffer.concat([opad, blake2b256(Buffer.concat([ipad, Buffer.from(data)]))])); +} + +export function hkdfBlake2b256(ikm, salt, info, length) { + const prk = hmacBlake2b256(salt && salt.length ? salt : Buffer.alloc(32), ikm); + const out = []; + let t = Buffer.alloc(0); + for (let i = 1; Buffer.concat(out).length < length; i++) { + t = hmacBlake2b256(prk, Buffer.concat([t, Buffer.from(info || []), Buffer.from([i])])); + out.push(t); + } + return Buffer.concat(out).subarray(0, length); +} + +// ---- XChaCha20-Poly1305 (open only) ------------------------------------------------ +const rotl = (v, n) => ((v << n) | (v >>> (32 - n))) >>> 0; +function quarter(s, a, b, c, d) { + s[a] = (s[a] + s[b]) >>> 0; s[d] = rotl(s[d] ^ s[a], 16); + s[c] = (s[c] + s[d]) >>> 0; s[b] = rotl(s[b] ^ s[c], 12); + s[a] = (s[a] + s[b]) >>> 0; s[d] = rotl(s[d] ^ s[a], 8); + s[c] = (s[c] + s[d]) >>> 0; s[b] = rotl(s[b] ^ s[c], 7); +} +export function hchacha20(key, nonce16) { + const s = new Uint32Array(16); + s.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); + for (let i = 0; i < 8; i++) s[4 + i] = key.readUInt32LE(i * 4); + for (let i = 0; i < 4; i++) s[12 + i] = nonce16.readUInt32LE(i * 4); + for (let r = 0; r < 10; r++) { + quarter(s, 0, 4, 8, 12); quarter(s, 1, 5, 9, 13); quarter(s, 2, 6, 10, 14); quarter(s, 3, 7, 11, 15); + quarter(s, 0, 5, 10, 15); quarter(s, 1, 6, 11, 12); quarter(s, 2, 7, 8, 13); quarter(s, 3, 4, 9, 14); + } + const out = Buffer.alloc(32); + [0, 1, 2, 3, 12, 13, 14, 15].forEach((w, i) => out.writeUInt32LE(s[w], i * 4)); + return out; +} +export function xchachaOpen(key, nonce24, sealed) { + const sub = hchacha20(Buffer.from(key), Buffer.from(nonce24).subarray(0, 16)); + const nonce12 = Buffer.concat([Buffer.alloc(4), Buffer.from(nonce24).subarray(16, 24)]); + const ct = Buffer.from(sealed); + const d = crypto.createDecipheriv('chacha20-poly1305', sub, nonce12, { authTagLength: 16 }); + d.setAuthTag(ct.subarray(ct.length - 16)); + return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]); +} + +// ---- s3d's database: / -> Sia object id ------------------------------- +export async function siaObjectId(dataDir, bucket, key) { + const file = path.join(dataDir, 's3d.db'); + if (!fs.existsSync(file)) throw new Error('s3d has no database yet'); + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(file, { readOnly: true }); + try { + const row = db.prepare(`SELECT o.sia_object_id AS id, o.size AS size FROM objects o JOIN buckets b ON b.id = o.bucket_id + WHERE b.name = ? AND o.name = ? AND o.is_latest = 1 AND o.is_delete_marker = 0`).get(bucket, key); + if (!row) return null; + return { id: row.id ? Buffer.from(row.id) : null, size: Number(row.size) }; + } finally { db.close(); } +} + +const b64urlPadded = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + +// The shared-object URL for one file, valid until validUntil (unix seconds). +export async function createShareUrl(dataDir, bucket, key, validUntil, { fetchImpl = fetch } = {}) { + const conn = await readConnection(dataDir); + if (!conn?.appKey || !conn.indexerUrl) throw Object.assign(new Error('s3d is not connected to a Sia account'), { status: 409 }); + const obj = await siaObjectId(dataDir, bucket, key); + if (!obj) throw Object.assign(new Error('no such file'), { status: 404 }); + if (!obj.id) throw Object.assign(new Error('This file has not reached Sia yet. Upload it now (Overview › Upload now) and try again.'), { status: 409, code: 'pending' }); + const base = conn.indexerUrl.replace(/\/+$/, ''); + const idHex = obj.id.toString('hex'); + const res = await fetchImpl(signRequest(conn.appKey, 'GET', `${base}/objects/${idHex}`, Math.floor(Date.now() / 1000) + 120), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) }); + if (!res.ok) throw new Error(`indexer answered ${res.status}: ${(await res.text()).slice(0, 160)}`); + const sealed = await res.json(); + const enc = Buffer.from(sealed.encryptedDataKey || '', 'base64'); + if (enc.length < 24 + 16) throw new Error('the indexer returned no data key'); + const kek = hkdfBlake2b256(conn.appKey, obj.id, Buffer.from('dataKey'), 32); + const dataKey = xchachaOpen(kek, enc.subarray(0, 24), enc.subarray(24)); + kek.fill(0); + const u = signRequest(conn.appKey, 'GET', `${base}/objects/${idHex}/shared`, validUntil); + u.hash = `encryption_key=${b64urlPadded(dataKey)}`; + dataKey.fill(0); + return { url: u.toString(), size: obj.size, objectId: idHex }; +} diff --git a/sia-share/deploy/README.md b/sia-share/deploy/README.md new file mode 100644 index 0000000..0bf27db --- /dev/null +++ b/sia-share/deploy/README.md @@ -0,0 +1,15 @@ +# sia-share deployment + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o sia-share . +install -m 0755 sia-share /usr/local/bin/sia-share +useradd --system --home /var/lib/sia-share --shell /usr/sbin/nologin siashare +install -d -o siashare -g siashare -m 700 /var/lib/sia-share +cp deploy/sia-share.example.json /etc/sia-share.json +runuser -u siashare -- sia-share register --data /var/lib/sia-share # approve the printed link once +cp deploy/sia-share.service /etc/systemd/system/ && systemctl enable --now sia-share +``` + +The web server sends `navigate.st/sia/` and `navigate.st/api/sia/` to `127.0.0.1:8730` +(stream bodies, no buffering). `links.json` in the data folder holds every link; back it up +with `app.key`. diff --git a/sia-share/deploy/sia-share.example.json b/sia-share/deploy/sia-share.example.json new file mode 100644 index 0000000..24a8255 --- /dev/null +++ b/sia-share/deploy/sia-share.example.json @@ -0,0 +1,9 @@ +{ + "listen": "127.0.0.1:8730", + "indexer": "https://sia.storage", + "publicBase": "https://navigate.st/sia", + "allowedIndexers": ["sia.storage"], + "perIpPerDay": 30, + "dailyBytesPerLink": 21474836480, + "maxFilesPerFolder": 1000 +} diff --git a/sia-share/deploy/sia-share.service b/sia-share/deploy/sia-share.service new file mode 100644 index 0000000..f59f6c1 --- /dev/null +++ b/sia-share/deploy/sia-share.service @@ -0,0 +1,18 @@ +[Unit] +Description=sia-share: navigate.st/sia links to files in people's own Sia accounts +After=network-online.target +Wants=network-online.target + +[Service] +User=siashare +Group=siashare +ExecStart=/usr/local/bin/sia-share serve --data /var/lib/sia-share --config /etc/sia-share.json +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/sia-share +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/sia-share/go.mod b/sia-share/go.mod new file mode 100644 index 0000000..da8c548 --- /dev/null +++ b/sia-share/go.mod @@ -0,0 +1,33 @@ +module code.silentmode.st/silentmode/pithos/sia-share + +go 1.27.1 + +require ( + go.sia.tech/core v0.21.7 + go.sia.tech/siastorage v0.2.2-0.20260831091740-f9961f7e5391 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/reedsolomon v1.14.2 // indirect + github.com/oschwald/geoip2-golang v1.13.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect + github.com/quic-go/webtransport-go v0.12.0 // indirect + go.sia.tech/coreutils v0.24.0 // indirect + go.sia.tech/indexd v0.4.5-0.20260831084636-eb58007edc01 // indirect + go.sia.tech/jape v0.14.1 // indirect + go.sia.tech/mux v1.5.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect + lukechampine.com/frand v1.5.1 // indirect +) diff --git a/sia-share/go.sum b/sia-share/go.sum new file mode 100644 index 0000000..923bfbd --- /dev/null +++ b/sia-share/go.sum @@ -0,0 +1,84 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ= +github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/quic-go/webtransport-go v0.12.0 h1:CpnKNwZvdV0LD73xoHO8QaR0NI3llqpWRwnazdZS0sE= +github.com/quic-go/webtransport-go v0.12.0/go.mod h1:GHne8aRFJ24h73pAMrcywXtuaz/ShBXCLXLvG/NPFdU= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.sia.tech/core v0.21.7 h1:Qgi2293i/d+UfpuVGlAcXfDY0Vzkj/GTjpkuEBXmIks= +go.sia.tech/core v0.21.7/go.mod h1:80xXoUUnfIFVazv7i4qZH4e/+kbxSadd4B3EK1+MOtw= +go.sia.tech/coreutils v0.24.0 h1:xz3CJ3SS38cGTF6WVxmZ4dR6t+2LostNscyZ6n2hbOI= +go.sia.tech/coreutils v0.24.0/go.mod h1:xNzCC31sJkKXVnEjv12aHXutRfc4nPDl53sVJQhw6+k= +go.sia.tech/indexd v0.4.5-0.20260831084636-eb58007edc01 h1:hKemTV2JdYuKXy0xWN4ry8g6wzbGoP+S5FOfK9LvuRk= +go.sia.tech/indexd v0.4.5-0.20260831084636-eb58007edc01/go.mod h1:ftyeJhFDBalcYxNvEbn3vpbqLv171WpsiRUdxYAY+9I= +go.sia.tech/jape v0.14.1 h1:3QWpOzAxxcaECuv2Mc6tbkFh+olLnyUxxP5SfwgI/qY= +go.sia.tech/jape v0.14.1/go.mod h1:BEygF1DcgdWBenPa75iJ1b91FuxzLmKBxpglmx+C9BY= +go.sia.tech/mux v1.5.3 h1:0LSoSUMUThKYYHPha3i3YADdfBVaGHcI8UKXhALndHg= +go.sia.tech/mux v1.5.3/go.mod h1:cYRXgCdhC5kH+8f6knyQJ2Wzk6kB7Ndam1KuEt4gI9M= +go.sia.tech/siastorage v0.2.2-0.20260831091740-f9961f7e5391 h1:elE3z6r+iFFv4Bda+lZ9IybHTuC8/pw6//SENusCpdQ= +go.sia.tech/siastorage v0.2.2-0.20260831091740-f9961f7e5391/go.mod h1:bz6wX7OqU4KE4ga7VkLc2lfqrC/0NausULpeP7v0PrI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w= +lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q= diff --git a/sia-share/main.go b/sia-share/main.go new file mode 100644 index 0000000..73fdf85 --- /dev/null +++ b/sia-share/main.go @@ -0,0 +1,585 @@ +// sia-share serves files that people share from their own Sia account at +// navigate.st/sia//, so anyone can open them in a browser. +// +// Pithos makes a Sia shared-object URL for a file (it can read that one +// object and nothing else in the account) and registers it here; this +// service keeps a short id for it and streams the file from Sia hosts on +// request, with byte ranges so audio and video can seek. The data stays in +// the sharer's account; reading it from hosts is paid by this service's own +// app on Silent Mode's indexer account. +// +// sia-share register --data /var/lib/sia-share one-time: prints an approval link +// sia-share serve --data /var/lib/sia-share the HTTP service +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "html" + "io" + "log" + "mime" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "go.sia.tech/core/types" + sdk "go.sia.tech/siastorage" + "go.uber.org/zap" +) + +// The app's identity at the indexer. NEVER change it: keys registered under +// it stop working. +var appMeta = sdk.AppMetadata{ + ID: mustHash("ed4ccccf5e8912c4e91e37ce5842ded282ffde68accd39fe1265a46903749dc2"), + Name: "Pithos share", + Description: "Serves files shared from Pithos at navigate.st/sia", + ServiceURL: "https://navigate.st/sia", +} + +func mustHash(s string) (h types.Hash256) { + b, err := hex.DecodeString(s) + if err != nil || len(b) != 32 { + panic("bad app id") + } + copy(h[:], b) + return +} + +type config struct { + Listen string `json:"listen"` + Indexer string `json:"indexer"` + PublicBase string `json:"publicBase"` // https://navigate.st/sia + AllowedIndexers []string `json:"allowedIndexers"` // hosts share URLs may point at + PerIPPerDay int `json:"perIpPerDay"` // new links per client address per day + DailyBytes int64 `json:"dailyBytesPerLink"` + MaxFiles int `json:"maxFilesPerFolder"` +} + +type file struct { + Name string `json:"name"` + URL string `json:"url"` // shared-object URL, with #encryption_key + Size int64 `json:"size"` +} + +type link struct { + ID string `json:"id"` + Name string `json:"name"` + Files []file `json:"files"` // one entry for a single file + Folder bool `json:"folder"` + Created int64 `json:"created"` + DeleteHash string `json:"deleteHash"` + Day string `json:"day"` + DayBytes int64 `json:"dayBytes"` +} + +type store struct { + mu sync.Mutex + file string + links map[string]*link + ips map[string][]int64 +} + +func (s *store) load() error { + s.links = map[string]*link{} + s.ips = map[string][]int64{} + b, err := os.ReadFile(s.file) + if errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + return json.Unmarshal(b, &s.links) +} + +func (s *store) save() error { + b, _ := json.MarshalIndent(s.links, "", " ") + tmp := s.file + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.file) +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: sia-share register|serve --data [--config ]") + os.Exit(2) + } + fs := flag.NewFlagSet(os.Args[1], flag.ExitOnError) + dataDir := fs.String("data", "/var/lib/sia-share", "state folder (app key, links)") + cfgFile := fs.String("config", "/etc/sia-share.json", "config file") + fs.Parse(os.Args[2:]) + cfg := config{Listen: "127.0.0.1:8730", Indexer: "https://sia.storage", PublicBase: "https://navigate.st/sia", + AllowedIndexers: []string{"sia.storage"}, PerIPPerDay: 30, DailyBytes: 20 << 30, MaxFiles: 1000} + if b, err := os.ReadFile(*cfgFile); err == nil { + if err := json.Unmarshal(b, &cfg); err != nil { + log.Fatalf("config: %v", err) + } + } + switch os.Args[1] { + case "register": + register(cfg, *dataDir) + case "serve": + serve(cfg, *dataDir) + default: + log.Fatalf("unknown command %q", os.Args[1]) + } +} + +// register connects this service to the indexer as its own app. The person +// who owns the paying account opens the printed link once and approves it. +func register(cfg config, dataDir string) { + keyFile := filepath.Join(dataDir, "app.key") + if _, err := os.Stat(keyFile); err == nil { + log.Fatalf("%s exists; delete it first to register again", keyFile) + } + if err := os.MkdirAll(dataDir, 0o700); err != nil { + log.Fatal(err) + } + ctx := context.Background() + b := sdk.NewBuilder(cfg.Indexer, appMeta) + approvalURL, err := b.RequestConnection(ctx) + if err != nil { + log.Fatalf("request connection: %v", err) + } + fmt.Printf("Open this link, sign in to the account that pays for downloads, and approve \"%s\":\n\n %s\n\nWaiting for approval…\n", appMeta.Name, approvalURL) + if err := b.WaitForApproval(ctx); err != nil { + log.Fatalf("approval: %v", err) + } + phrase := sdk.NewSeedPhrase() + s, err := b.Register(ctx, phrase) + if err != nil { + log.Fatalf("register: %v", err) + } + key := s.AppKey() + if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(key[:])), 0o600); err != nil { + log.Fatal(err) + } + fmt.Printf("Registered. App key saved to %s (keep it private; it pays for downloads).\n", keyFile) +} + +func loadAppKey(dataDir string) types.PrivateKey { + b, err := os.ReadFile(filepath.Join(dataDir, "app.key")) + if err != nil { + log.Fatalf("no app key: run 'sia-share register' first (%v)", err) + } + raw, err := hex.DecodeString(strings.TrimSpace(string(b))) + if err != nil || len(raw) != 64 { + log.Fatal("app.key is not a 64-byte hex key") + } + return types.PrivateKey(raw) +} + +var ( + sharedPath = regexp.MustCompile(`^/objects/[0-9a-f]{64}/shared$`) + idRe = regexp.MustCompile(`^[A-Za-z0-9_-]{6,32}$`) +) + +func serve(cfg config, dataDir string) { + appKey := loadAppKey(dataDir) + client, err := sdk.NewBuilder(cfg.Indexer, appMeta).SDK(appKey, sdk.WithLogger(zap.NewNop())) + if err != nil { + log.Fatalf("connect to indexer: %v", err) + } + st := &store{file: filepath.Join(dataDir, "links.json")} + if err := st.load(); err != nil { + log.Fatalf("links: %v", err) + } + h := &handler{cfg: cfg, sdk: client, st: st} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/sia/links", h.create) + mux.HandleFunc("DELETE /api/sia/links/{id}", h.remove) + mux.HandleFunc("GET /sia/{id}", h.get) + mux.HandleFunc("HEAD /sia/{id}", h.get) + mux.HandleFunc("GET /sia/{id}/{rest...}", h.get) + mux.HandleFunc("HEAD /sia/{id}/{rest...}", h.get) + srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 20 * time.Second} + log.Printf("sia-share on %s for %s", cfg.Listen, cfg.PublicBase) + log.Fatal(srv.ListenAndServe()) +} + +type handler struct { + cfg config + sdk *sdk.SDK + st *store +} + +func jsonOut(w http.ResponseWriter, status int, v any) { + w.Header().Set("content-type", "application/json; charset=utf-8") + w.Header().Set("cache-control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func clientIP(r *http.Request) string { + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + return strings.TrimSpace(strings.Split(xf, ",")[0]) + } + host, _, _ := net.SplitHostPort(r.RemoteAddr) + return host +} + +func randomID(n int) string { + b := make([]byte, n) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func cleanName(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\\", "/")) + s = path.Base("/" + s) + if s == "/" || s == "." || s == ".." || len(s) > 255 { + return "" + } + return s +} + +// sizeOf checks a shared-object URL against its indexer and returns the +// object's size. Only URLs on an allowed indexer, in the shared-object form +// and carrying a key, are accepted. +func (h *handler) sizeOf(ctx context.Context, raw string) (int64, error) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || !sharedPath.MatchString(u.Path) { + return 0, errors.New("not a Sia shared-object URL") + } + allowed := false + for _, a := range h.cfg.AllowedIndexers { + if strings.EqualFold(u.Host, a) { + allowed = true + } + } + if !allowed { + return 0, fmt.Errorf("indexer %q is not accepted here", u.Host) + } + if v, _ := url.ParseQuery(u.Fragment); len(v.Get("encryption_key")) < 40 { + return 0, errors.New("the URL has no encryption key") + } + q := *u + q.Fragment = "" + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, q.String(), nil) + req.Header.Set("accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("indexer: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, fmt.Errorf("indexer refused the link (%s); it may have expired", resp.Status) + } + var obj struct { + Slabs []struct { + Length int64 `json:"length"` + } `json:"slabs"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&obj); err != nil { + return 0, fmt.Errorf("indexer: %w", err) + } + var n int64 + for _, s := range obj.Slabs { + n += s.Length + } + return n, nil +} + +// POST /api/sia/links {name, url} for one file, or {name, folder: true, +// files: [{name, url}]} for a folder. Answers {id, url, deleteToken}. +func (h *handler) create(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + URL string `json:"url"` + Folder bool `json:"folder"` + Files []file `json:"files"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&body); err != nil { + jsonOut(w, 400, map[string]string{"error": "invalid JSON"}) + return + } + ip := clientIP(r) + now := time.Now().Unix() + h.st.mu.Lock() + recent := []int64{} + for _, t := range h.st.ips[ip] { + if now-t < 86400 { + recent = append(recent, t) + } + } + if h.cfg.PerIPPerDay > 0 && len(recent) >= h.cfg.PerIPPerDay { + h.st.mu.Unlock() + jsonOut(w, 429, map[string]string{"error": "too many new links from this address today"}) + return + } + h.st.mu.Unlock() + + files := body.Files + if !body.Folder { + files = []file{{Name: body.Name, URL: body.URL}} + } + if len(files) == 0 || len(files) > h.cfg.MaxFiles { + jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("between 1 and %d files", h.cfg.MaxFiles)}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + seen := map[string]bool{} + for i := range files { + files[i].Name = cleanName(files[i].Name) + if files[i].Name == "" || seen[files[i].Name] { + jsonOut(w, 400, map[string]string{"error": "every file needs a distinct name"}) + return + } + seen[files[i].Name] = true + n, err := h.sizeOf(ctx, files[i].URL) + if err != nil { + jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("%s: %v", files[i].Name, err)}) + return + } + files[i].Size = n + } + name := cleanName(body.Name) + if name == "" { + name = files[0].Name + } + token := randomID(24) + sum := sha256.Sum256([]byte(token)) + l := &link{ID: randomID(6), Name: name, Files: files, Folder: body.Folder, Created: now, DeleteHash: hex.EncodeToString(sum[:])} + + h.st.mu.Lock() + for h.st.links[l.ID] != nil { + l.ID = randomID(6) + } + h.st.links[l.ID] = l + h.st.ips[ip] = append(recent, now) + err := h.st.save() + h.st.mu.Unlock() + if err != nil { + jsonOut(w, 500, map[string]string{"error": "could not save the link"}) + return + } + public := h.cfg.PublicBase + "/" + l.ID + "/" + if !l.Folder { + public += url.PathEscape(files[0].Name) + } + log.Printf("[%s] new %s link %q (%d file(s))", l.ID, map[bool]string{true: "folder", false: "file"}[l.Folder], name, len(files)) + jsonOut(w, 200, map[string]any{"id": l.ID, "url": public, "deleteToken": token}) +} + +// DELETE /api/sia/links/{id} with header X-Delete-Token: the link stops working here. +func (h *handler) remove(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + sum := sha256.Sum256([]byte(r.Header.Get("X-Delete-Token"))) + h.st.mu.Lock() + defer h.st.mu.Unlock() + l := h.st.links[id] + if l == nil || l.DeleteHash != hex.EncodeToString(sum[:]) { + jsonOut(w, 404, map[string]string{"error": "no such link"}) + return + } + delete(h.st.links, id) + h.st.save() + jsonOut(w, 200, map[string]bool{"ok": true}) +} + +func (h *handler) get(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !idRe.MatchString(id) { + http.NotFound(w, r) + return + } + h.st.mu.Lock() + l := h.st.links[id] + h.st.mu.Unlock() + if l == nil { + notFound(w) + return + } + rest := r.PathValue("rest") + if l.Folder { + if rest == "" { + if !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, id+"/", http.StatusMovedPermanently) + return + } + folderPage(w, l) + return + } + for _, f := range l.Files { + if f.Name == rest { + h.stream(w, r, l, f) + return + } + } + notFound(w) + return + } + h.stream(w, r, l, l.Files[0]) +} + +func notFound(w http.ResponseWriter) { + w.Header().Set("content-type", "text/html; charset=utf-8") + w.WriteHeader(404) + io.WriteString(w, `Not found

This shared link does not exist, or its owner switched it off.

`) +} + +func parseRange(hdr string, size int64) (off, length int64, partial, ok bool) { + if hdr == "" { + return 0, size, false, true + } + m := regexp.MustCompile(`^bytes=(\d*)-(\d*)$`).FindStringSubmatch(strings.TrimSpace(hdr)) + if m == nil { + return 0, size, false, true // unsupported forms: whole file + } + if m[1] == "" { // suffix: last N bytes + n, err := strconv.ParseInt(m[2], 10, 64) + if err != nil || n <= 0 { + return 0, 0, false, false + } + if n > size { + n = size + } + return size - n, n, true, true + } + start, err := strconv.ParseInt(m[1], 10, 64) + if err != nil || start >= size { + return 0, 0, false, false + } + end := size - 1 + if m[2] != "" { + if e, err := strconv.ParseInt(m[2], 10, 64); err == nil && e < end { + end = e + } + } + if end < start { + return 0, 0, false, false + } + return start, end - start + 1, true, true +} + +// stream sends one file from Sia, honouring Range, under the link's daily cap. +// Every file is served sandboxed: a shared HTML or SVG file cannot run script +// on navigate.st. +func (h *handler) stream(w http.ResponseWriter, r *http.Request, l *link, f file) { + off, length, partial, ok := parseRange(r.Header.Get("Range"), f.Size) + if !ok { + w.Header().Set("content-range", fmt.Sprintf("bytes */%d", f.Size)) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + today := time.Now().UTC().Format("2006-01-02") + h.st.mu.Lock() + if l.Day != today { + l.Day, l.DayBytes = today, 0 + } + over := h.cfg.DailyBytes > 0 && l.DayBytes+length > h.cfg.DailyBytes + if !over && r.Method != http.MethodHead { + l.DayBytes += length + } + h.st.mu.Unlock() + if over { + w.Header().Set("retry-after", "3600") + http.Error(w, "this link has used today's download allowance; try again tomorrow", http.StatusTooManyRequests) + return + } + ct := mime.TypeByExtension(strings.ToLower(path.Ext(f.Name))) + if ct == "" { + ct = "application/octet-stream" + } + hd := w.Header() + hd.Set("content-type", ct) + hd.Set("accept-ranges", "bytes") + hd.Set("content-length", strconv.FormatInt(length, 10)) + hd.Set("content-disposition", mime.FormatMediaType("inline", map[string]string{"filename": f.Name})) + hd.Set("content-security-policy", "sandbox") + hd.Set("x-content-type-options", "nosniff") + hd.Set("cache-control", "public, max-age=300") + status := http.StatusOK + if partial { + hd.Set("content-range", fmt.Sprintf("bytes %d-%d/%d", off, off+length-1, f.Size)) + status = http.StatusPartialContent + } + if r.Method == http.MethodHead || length == 0 { + w.WriteHeader(status) + return + } + rc, err := h.sdk.DownloadSharedObject(r.Context(), f.URL, sdk.WithDownloadRange(uint64(off), uint64(length))) + if err != nil { + log.Printf("[%s] %s: %v", l.ID, f.Name, err) + http.Error(w, "could not read the file from Sia right now", http.StatusBadGateway) + return + } + defer rc.Close() + w.WriteHeader(status) + if _, err := io.Copy(w, rc); err != nil && !errors.Is(err, context.Canceled) { + log.Printf("[%s] %s: stream: %v", l.ID, f.Name, err) + } +} + +func fmtSize(n int64) string { + units := []string{"B", "KiB", "MiB", "GiB", "TiB"} + f := float64(n) + i := 0 + for f >= 1024 && i < len(units)-1 { + f /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%d B", n) + } + return fmt.Sprintf("%.1f %s", f, units[i]) +} + +func folderPage(w http.ResponseWriter, l *link) { + kind := func(n string) string { + switch strings.ToLower(path.Ext(n)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif": + return "img" + case ".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".flac", ".wav": + return "audio" + case ".mp4", ".webm", ".mov", ".m4v": + return "video" + } + return "" + } + var rows strings.Builder + for _, f := range l.Files { + href := url.PathEscape(f.Name) + media := "" + switch k := kind(f.Name); k { + case "img": + media = fmt.Sprintf(``, href) + case "audio", "video": + media = fmt.Sprintf(``, href, k) + } + fmt.Fprintf(&rows, `
  • %s%s%s
  • `, media, href, html.EscapeString(f.Name), fmtSize(f.Size)) + } + w.Header().Set("content-type", "text/html; charset=utf-8") + w.Header().Set("content-security-policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self'; media-src 'self'; frame-ancestors 'none'") + w.Header().Set("x-content-type-options", "nosniff") + fmt.Fprintf(w, ` +%s · shared from Sia + +
    Shared from Sia

    %s

      %s
    +
    Stored on the Sia network and shared with Pithos.
    + +`, html.EscapeString(l.Name), html.EscapeString(l.Name), rows.String()) +} diff --git a/sia-share/main_test.go b/sia-share/main_test.go new file mode 100644 index 0000000..f119cad --- /dev/null +++ b/sia-share/main_test.go @@ -0,0 +1,37 @@ +package main + +import "testing" + +func TestParseRange(t *testing.T) { + cases := []struct { + hdr string + size, off, length int64 + partial, ok bool + }{ + {"", 100, 0, 100, false, true}, + {"bytes=0-9", 100, 0, 10, true, true}, + {"bytes=90-", 100, 90, 10, true, true}, + {"bytes=-5", 100, 95, 5, true, true}, + {"bytes=-500", 100, 0, 100, true, true}, + {"bytes=50-1000", 100, 50, 50, true, true}, + {"bytes=100-", 100, 0, 0, false, false}, + {"bytes=9-3", 100, 0, 0, false, false}, + {"bytes=0-1,5-6", 100, 0, 100, false, true}, // multi-range: whole file + } + for _, c := range cases { + off, length, partial, ok := parseRange(c.hdr, c.size) + if ok != c.ok || (ok && (off != c.off || length != c.length || partial != c.partial)) { + t.Errorf("%q/%d: got %d,%d,%v,%v want %d,%d,%v,%v", c.hdr, c.size, off, length, partial, ok, c.off, c.length, c.partial, c.ok) + } + } +} + +func TestCleanName(t *testing.T) { + for in, want := range map[string]string{ + "song.mp3": "song.mp3", "a/b/c.txt": "c.txt", `..\..\x.png`: "x.png", "..": "", "": "", " photo.jpg ": "photo.jpg", + } { + if got := cleanName(in); got != want { + t.Errorf("cleanName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/test/sia-share.test.js b/test/sia-share.test.js new file mode 100644 index 0000000..8496280 --- /dev/null +++ b/test/sia-share.test.js @@ -0,0 +1,38 @@ +// Sia share links: the data key is unwrapped exactly as the Sia SDK does it. +// Vectors were produced with golang.org/x/crypto (hkdf + blake2b.New256, +// chacha20poly1305.NewX), the calls indexd keys.Derive and siastorage use. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hkdfBlake2b256, hmacBlake2b256, hchacha20, xchachaOpen } from '../core/sia-share.js'; + +const H = (x) => Buffer.from(x, 'hex'); +const V = { + appKey: '8985f2c92de21efb5ed77756b23f0d1045505f573f12046c5aebf5d30ba384ab2041560b41c83e59ee41030ccf72b53a7724f65463fb2c81dc3e1aa179e500f6', + objectId: 'a0adf11767768dcaa3e7b2367b191cd29c121ca0e4a2d479995ab6bd55a10a0a', + kek: 'aa62f4b76f7956e9275811021b493eb35365b38326f4ca8d2d6220fd69647ddf', + encryptedDataKey: '75b150c47e412e8353420dffe0096e514c2ac4edca06fd9301131ff1c5b31560173bf90185a396143d011775cfee27a68a6150a2b657db1b99f08fa9d91bd31bca28d6a14abd89a4', + dataKey: '6f73cb85e254a32ebf32eda4e027305c40207fa1688fa456196c538d71d83eac', + longOkm: '7a113b37916fdc7c05e4edc4c15826475bbf15320e68c341707d95c907fabcc99ad9d14a8b36895b14696e3fbdc5f3dc0c878e045bb733df6fe86a779b67c841659ab68e138aee453ca9218cc5ddabe1', +}; + +test('HKDF-BLAKE2b-256 matches keys.Derive', () => { + assert.equal(hkdfBlake2b256(H(V.appKey), H(V.objectId), Buffer.from('dataKey'), 32).toString('hex'), V.kek); + assert.equal(hkdfBlake2b256(H(V.appKey), Buffer.alloc(0), Buffer.from('x'), 80).toString('hex'), V.longOkm); +}); + +test('HChaCha20 matches draft-irtf-cfrg-xchacha 2.2.1', () => { + const key = Buffer.from([...Array(32).keys()]); + assert.equal(hchacha20(key, H('000000090000004a0000000031415927')).toString('hex'), '82413b4227b27bfed30e42508a877d73a0f9e4d58a74a853c12ec41326d3ecdc'); +}); + +test('XChaCha20-Poly1305 opens a key sealed by chacha20poly1305.NewX, and rejects tampering', () => { + const enc = H(V.encryptedDataKey); + assert.equal(xchachaOpen(H(V.kek), enc.subarray(0, 24), enc.subarray(24)).toString('hex'), V.dataKey); + const bad = Buffer.from(enc); bad[30] ^= 1; + assert.throws(() => xchachaOpen(H(V.kek), bad.subarray(0, 24), bad.subarray(24))); +}); + +test('HMAC-BLAKE2b-256 hashes long keys first (RFC 2104)', () => { + assert.equal(hmacBlake2b256(Buffer.alloc(200, 7), Buffer.from('m')).length, 32); + assert.notDeepEqual(hmacBlake2b256(Buffer.alloc(129, 1), 'm'), hmacBlake2b256(Buffer.alloc(128, 1), 'm')); +}); diff --git a/ui/app.js b/ui/app.js index 125d860..c8ec260 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1563,7 +1563,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const fileName = (o) => o.key.slice(prefix.length); - const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]); + const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() ? ['Share…', () => folderShareDialog(user, bucket, f)] : ['Public link…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]); const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); @@ -1806,15 +1806,44 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { expires.addEventListener('change', gen); endpoint.addEventListener('change', gen); gen(); - await modal('Share link', h('div', { class: 'stack' }, - h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'), - h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), - out, - note, + await modal('Share', h('div', { class: 'stack' }, + siaLinkBlock({ key }), h('details', { class: 'more-opts', open: !!savedEndpoint }, - h('summary', {}, 'Use my own public address'), - h('label', { class: 'field', style: 'margin-top:8px' }, endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.')))), - [{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]); + h('summary', {}, 'Link for this computer, or your own server'), + h('div', { class: 'stack', style: 'margin-top:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link straight to s3d that works without a key until it expires.'), + h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), + out, + note, + h('label', { class: 'field' }, h('span', {}, 'Your own public address'), endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.'))))), + [{ label: 'Done', kind: 'primary', submit: true, result: true }]); + } + + // A public link anyone can open, at navigate.st/sia: the file is read from + // your Sia account through Silent Mode, so it works with this computer off. + function siaLinkBlock({ key, prefix }) { + const days = h('select', {}, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l))); + const out = h('div'); + const btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => { + btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Making the link…'); + try { + const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) }); + put(out, secretRow('Public link', r.url), h('p', { class: 'small muted', style: 'margin:0' }, `Works until ${new Date(r.until * 1000).toLocaleDateString()}. Switch it off any time in Settings › Public links.`)); + put(btn, 'Make another link'); + } catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); } + btn.disabled = false; + } }, 'Create public link'); + return h('div', { class: 'stack' }, + h('h3', { style: 'margin:0' }, 'Public link'), + h('p', { class: 'small muted', style: 'margin:0' }, prefix + ? 'A page with the files in this folder that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.' + : 'Anyone with the link can open this file on any device, even with this computer off. It is read from your Sia account through navigate.st; only this file is shared.'), + h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn), + out); + } + + async function siaFolderLink(prefix) { + await modal(`Public link for ${prefix.slice(0, -1).split('/').pop()}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } // On Silent Mode a link is a rule on the server, and the file is stored @@ -1840,6 +1869,24 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { load(); } +// Settings › Public links: links made at navigate.st/sia, each can be switched off. +async function publicLinksCard(card) { + let list = []; + try { list = await api('GET', '/api/sia-links'); } catch { card.remove(); return; } + if (!list.length) { card.remove(); return; } + put(card, h('h2', {}, 'Public links'), + h('p', { class: 'small muted' }, 'Links anyone can open at navigate.st/sia. Switching one off stops it at once.'), + h('table', {}, h('tbody', {}, list.map((l) => h('tr', {}, + h('td', {}, h('a', { href: l.url, target: '_blank', rel: 'noopener' }, l.prefix ? `📁 ${l.bucket}/${l.prefix}` : `${l.bucket}/${l.key}`), + h('div', { class: 'small muted' }, `until ${new Date(l.until * 1000).toLocaleDateString()}${l.prefix ? ` · ${l.files} files` : ''}`)), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: () => copy(l.url, 'Link copied') }, 'Copy'), ' ', + h('button', { class: 'btn small danger', onclick: async () => { + if (!(await confirmModal('Switch this link off?', 'Anyone who has it will no longer be able to open it.', 'Switch off'))) return; + try { await api('DELETE', `/api/sia-links/${encodeURIComponent(l.id)}`); toast('Link switched off'); publicLinksCard(card); } catch (e) { fail(e); } + } }, 'Switch off'))))))); +} + // ---------------------------------------------------------------- on Silent Mode const isHosted = () => state.status?.mode === 'hosted'; @@ -2260,6 +2307,9 @@ function settings(main) { const whereCard = h('div', { class: 'card', style: 'margin-bottom:16px' }); main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Where your drives live, and s3d.yml for s3d on this computer. s3d reads it at start, so a restart applies the changes.'))), whereCard, card, flushCard); hostedCard(whereCard); + const linksCard = h('div', { class: 'card', style: 'margin-top:16px' }); + main.append(linksCard); + publicLinksCard(linksCard); // s3d.yml and the leftovers setting are for s3d on this computer; on Silent // Mode the server manages both (leftovers always go to Sia). if (isHosted()) {