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.
This commit is contained in:
parent
b6c6e96c7d
commit
24709d582d
11 changed files with 1047 additions and 9 deletions
|
|
@ -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/<id>/<name> -----------------------------------
|
||||
// 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 };
|
||||
|
|
|
|||
107
core/sia-share.js
Normal file
107
core/sia-share.js
Normal file
|
|
@ -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 <drive>/<name> to the object's Sia id;
|
||||
// 2. the indexer returns the sealed object (signed GET /objects/<id>);
|
||||
// 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/<id>/shared, valid until a date,
|
||||
// with #encryption_key=<data 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: <drive>/<name> -> 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 };
|
||||
}
|
||||
15
sia-share/deploy/README.md
Normal file
15
sia-share/deploy/README.md
Normal file
|
|
@ -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`.
|
||||
9
sia-share/deploy/sia-share.example.json
Normal file
9
sia-share/deploy/sia-share.example.json
Normal file
|
|
@ -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
|
||||
}
|
||||
18
sia-share/deploy/sia-share.service
Normal file
18
sia-share/deploy/sia-share.service
Normal file
|
|
@ -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
|
||||
33
sia-share/go.mod
Normal file
33
sia-share/go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
84
sia-share/go.sum
Normal file
84
sia-share/go.sum
Normal file
|
|
@ -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=
|
||||
585
sia-share/main.go
Normal file
585
sia-share/main.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
// sia-share serves files that people share from their own Sia account at
|
||||
// navigate.st/sia/<id>/<name>, 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 <dir> [--config <file>]")
|
||||
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, `<!doctype html><meta charset="utf-8"><title>Not found</title><body style="font:16px system-ui;background:#0b100e;color:#e6ece9;display:grid;place-items:center;min-height:90vh"><p>This shared link does not exist, or its owner switched it off.</p>`)
|
||||
}
|
||||
|
||||
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(`<img loading="lazy" src="%s" alt="">`, href)
|
||||
case "audio", "video":
|
||||
media = fmt.Sprintf(`<button type="button" class="play" data-src="%s" data-kind="%s">▶</button>`, href, k)
|
||||
}
|
||||
fmt.Fprintf(&rows, `<li>%s<a href="%s">%s</a><span class="sz">%s</span></li>`, 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, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s · shared from Sia</title>
|
||||
<style>body{margin:0;background:#0b100e;color:#e6ece9;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:860px;margin:0 auto;padding:28px 18px 120px}
|
||||
a{color:#3ddc97;text-decoration:none}a:hover{text-decoration:underline}.owner{color:#8e9c96;font-size:13px}h1{font-size:20px;margin:4px 0 18px;font-weight:600}
|
||||
ul{list-style:none;padding:0;margin:0;border:1px solid rgba(255,255,255,.08);border-radius:10px;overflow:hidden}li{display:flex;align-items:center;gap:10px;padding:9px 14px;border-top:1px solid rgba(255,255,255,.06);min-width:0}li:first-child{border-top:none}
|
||||
li a{flex:1;min-width:0;overflow-wrap:anywhere}li img{width:44px;height:44px;object-fit:cover;border-radius:6px;flex:none}.sz{color:#8e9c96;font-size:13px;white-space:nowrap}
|
||||
.play{flex:none;width:32px;height:32px;border-radius:50%%;border:0;background:#3ddc97;color:#06231a;cursor:pointer;font-size:13px}
|
||||
#player{position:fixed;left:0;right:0;bottom:0;background:#121a17;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:none}#player.on{display:block}#player audio,#player video{width:100%%;max-height:50vh}footer{color:#5f6b66;font-size:12.5px;margin-top:18px}</style></head>
|
||||
<body><main><div class="owner">Shared from Sia</div><h1>%s</h1><ul>%s</ul>
|
||||
<footer>Stored on the Sia network and shared with Pithos.</footer></main><div id="player"></div>
|
||||
<script>document.addEventListener('click',function(e){var b=e.target.closest('.play');if(!b)return;var p=document.getElementById('player');var m=document.createElement(b.dataset.kind);m.src=b.dataset.src;m.controls=true;m.autoplay=true;p.innerHTML='';p.appendChild(m);p.className='on';});</script>
|
||||
</body></html>`, html.EscapeString(l.Name), html.EscapeString(l.Name), rows.String())
|
||||
}
|
||||
37
sia-share/main_test.go
Normal file
37
sia-share/main_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
test/sia-share.test.js
Normal file
38
test/sia-share.test.js
Normal file
|
|
@ -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'));
|
||||
});
|
||||
68
ui/app.js
68
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()) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue