Theseus: bundle Pithos 0.3.12
New installs carry the same Pithos the extension channel serves.
This commit is contained in:
parent
0514d2d4e3
commit
001fce02e7
9 changed files with 332 additions and 10 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pithos",
|
"id": "pithos",
|
||||||
"name": "Pithos",
|
"name": "Pithos",
|
||||||
"version": "0.3.11",
|
"version": "0.3.12",
|
||||||
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const FORWARD = [
|
||||||
['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/],
|
['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/],
|
||||||
['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/],
|
['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/],
|
||||||
['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/],
|
['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/],
|
||||||
|
['GET', /^\/api\/space$/],
|
||||||
];
|
];
|
||||||
// Things that act on this computer's s3d and make no sense while drives live
|
// Things that act on this computer's s3d and make no sense while drives live
|
||||||
// on the server.
|
// on the server.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import path from 'node:path';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { S3Client, readBody, objectPath } from './s3.js';
|
import { S3Client, S3Error, readBody, objectPath } from './s3.js';
|
||||||
import {
|
import {
|
||||||
driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN,
|
driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN,
|
||||||
} from './crypt.js';
|
} from './crypt.js';
|
||||||
|
|
@ -124,7 +124,8 @@ export function plainCovered(rules, bucket, key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript',
|
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript',
|
||||||
|
wasm: 'application/wasm', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', webmanifest: 'application/manifest+json',
|
||||||
json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip',
|
json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip',
|
||||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon',
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon',
|
||||||
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus',
|
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus',
|
||||||
|
|
@ -270,6 +271,50 @@ export class EncryptingS3Client {
|
||||||
return withStatus(out, status, headers);
|
return withStatus(out, status, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renames (the routes' renameObject / renamePrefix). Encrypted names and
|
||||||
|
// contents are bound to their path and drive, so only a readable file
|
||||||
|
// staying readable can use s3d's server-side copy; everything else streams
|
||||||
|
// through here, decrypted and encrypted again for its new place.
|
||||||
|
async renameObject(bucket, from, to, dstBucket = bucket) {
|
||||||
|
if (from === to && dstBucket === bucket) return;
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
const ek = encryptPath(dk, from);
|
||||||
|
const head = await this.inner.request('HEAD', objectPath(bucket, ek));
|
||||||
|
head.resume();
|
||||||
|
const srcPlain = head.statusCode === 404 || head.statusCode === 403;
|
||||||
|
if (srcPlain && await this.storeAsPlain(dstBucket, to)) {
|
||||||
|
await this.inner.renameObject(bucket, from, to, dstBucket);
|
||||||
|
// An older encrypted copy under the new name would shadow this one.
|
||||||
|
await this.inner.deleteObject(dstBucket, encryptPath(await this.dk(dstBucket), to)).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from.endsWith('/')) {
|
||||||
|
await this.putObject(dstBucket, to, Buffer.alloc(0), { contentLength: 0 });
|
||||||
|
} else {
|
||||||
|
const src = await this.getObject(bucket, from);
|
||||||
|
if (src.statusCode >= 300) { src.resume?.(); throw new Error(`could not read ${from}: HTTP ${src.statusCode}`); }
|
||||||
|
await this.putObject(dstBucket, to, src, { contentType: guessType(to), contentLength: Number(src.headers['content-length']) });
|
||||||
|
}
|
||||||
|
await this.deleteObject(bucket, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePrefix(bucket, from, to, dstBucket = bucket) {
|
||||||
|
// Sharing rules name their drive; a renamed drive would silently lose them.
|
||||||
|
if (dstBucket !== bucket && this.encryptWrites && (await this.rules()).some((r) => r.bucket === bucket)) {
|
||||||
|
throw new S3Error(409, 'Conflict', 'Stop sharing in this drive (Access) before renaming it; its shared folders and links belong to the old name.');
|
||||||
|
}
|
||||||
|
// Listed in full first, so the loop never walks into its own copies.
|
||||||
|
const keys = [];
|
||||||
|
let token;
|
||||||
|
do {
|
||||||
|
const page = await this.listObjects(bucket, { prefix: from, delimiter: '', token });
|
||||||
|
for (const o of page.objects) keys.push(o.key);
|
||||||
|
token = page.truncated ? page.nextToken : null;
|
||||||
|
} while (token);
|
||||||
|
for (const k of keys) await this.renameObject(bucket, k, to + k.slice(from.length), dstBucket);
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteObject(bucket, key) {
|
async deleteObject(bucket, key) {
|
||||||
const dk = await this.dk(bucket);
|
const dk = await this.dk(bucket);
|
||||||
const ek = encryptPath(dk, key);
|
const ek = encryptPath(dk, key);
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,39 @@ export class S3Client {
|
||||||
|
|
||||||
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
|
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
|
||||||
|
|
||||||
|
// Server-side copy. s3d copies the entry in its own database: the copy
|
||||||
|
// points at the same data on Sia, so nothing is uploaded again.
|
||||||
|
copyObject(srcBucket, srcKey, dstBucket, dstKey) {
|
||||||
|
return this.call('PUT', objectPath(dstBucket, dstKey), { headers: { 'x-amz-copy-source': objectPath(srcBucket, srcKey) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every key under prefix (no folder grouping), all pages.
|
||||||
|
async allKeys(bucket, prefix = '') {
|
||||||
|
const keys = [];
|
||||||
|
let token;
|
||||||
|
do {
|
||||||
|
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
||||||
|
keys.push(...page.objects.map((o) => o.key));
|
||||||
|
token = page.truncated ? page.nextToken : null;
|
||||||
|
} while (token);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 has no rename: copy to the new name, then delete the old one.
|
||||||
|
async renameObject(bucket, from, to, dstBucket = bucket) {
|
||||||
|
await this.copyObject(bucket, from, dstBucket, to);
|
||||||
|
await this.deleteObject(bucket, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename a folder (or move a whole drive's contents): every key under
|
||||||
|
// `from` moves under `to`. Keys are listed first, so the loop never
|
||||||
|
// walks into the copies it makes. Returns the number moved.
|
||||||
|
async renamePrefix(bucket, from, to, dstBucket = bucket) {
|
||||||
|
const keys = await this.allKeys(bucket, from);
|
||||||
|
for (const k of keys) await this.renameObject(bucket, k, to + k.slice(from.length), dstBucket);
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
// Deletes every key under prefix. Returns the number deleted.
|
// Deletes every key under prefix. Returns the number deleted.
|
||||||
async deletePrefix(bucket, prefix) {
|
async deletePrefix(bucket, prefix) {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import { readPrefs, writePrefs } from './prefs.js';
|
||||||
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
|
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
|
||||||
import { installHosted } from './hosted-routes.js';
|
import { installHosted } from './hosted-routes.js';
|
||||||
import { accountInfo, readConnection, fingerprint } from './sia-account.js';
|
import { accountInfo, readConnection, fingerprint } from './sia-account.js';
|
||||||
|
import { guessType } from './hosted.js';
|
||||||
|
import { accountSpace } from './space.js';
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const UI_DIR = path.join(HERE, '..', 'ui');
|
const UI_DIR = path.join(HERE, '..', 'ui');
|
||||||
|
|
@ -408,6 +410,75 @@ export async function createPithos(opts = {}) {
|
||||||
if (!prefix) throw new HttpError(400, 'prefix required');
|
if (!prefix) throw new HttpError(400, 'prefix required');
|
||||||
return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) };
|
return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) };
|
||||||
});
|
});
|
||||||
|
// ---- renames: files, folders, drives -----------------------------------
|
||||||
|
// Server-side copies, so they are quick and use no extra Sia storage.
|
||||||
|
// Every client Pithos makes can rename; the check stays for any that
|
||||||
|
// cannot (encrypted names and contents are tied to their path, so a raw
|
||||||
|
// server-side copy would break them).
|
||||||
|
async function renamer(user) {
|
||||||
|
const s3 = await s3For(user);
|
||||||
|
if (typeof s3.renamePrefix !== 'function') throw new HttpError(409, 'Renaming is not available for drives on Silent Mode yet.');
|
||||||
|
return s3;
|
||||||
|
}
|
||||||
|
const badName = (n) => !n || n.includes('/') || n === '.' || n === '..';
|
||||||
|
async function exists(s3, bucket, prefix) {
|
||||||
|
const page = await s3.listObjects(bucket, { prefix, delimiter: '', max: 1 });
|
||||||
|
return page.objects.some((o) => o.key === prefix || prefix.endsWith('/'));
|
||||||
|
}
|
||||||
|
// A file: { from: "<key>", name: "<new name>" } in the same folder.
|
||||||
|
route('POST', '/api/s3/:user/buckets/:bucket/rename-object', async (req, url, p) => {
|
||||||
|
const { from, name } = await jsonBody(req);
|
||||||
|
if (!from || from.endsWith('/') || badName(name)) throw new HttpError(400, 'a file and a new name (without "/") are required');
|
||||||
|
const to = from.slice(0, from.lastIndexOf('/') + 1) + name;
|
||||||
|
if (to === from) return { key: to };
|
||||||
|
const s3 = await renamer(p.user);
|
||||||
|
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `"${name}" already exists here`);
|
||||||
|
await s3.renameObject(p.bucket, from, to);
|
||||||
|
return { key: to };
|
||||||
|
});
|
||||||
|
// A folder: { from: "<prefix>/", name: "<new name>" } beside it.
|
||||||
|
route('POST', '/api/s3/:user/buckets/:bucket/rename-prefix', async (req, url, p) => {
|
||||||
|
const { from, name } = await jsonBody(req);
|
||||||
|
if (!from || !from.endsWith('/') || badName(name)) throw new HttpError(400, 'a folder and a new name (without "/") are required');
|
||||||
|
const parent = from.slice(0, from.slice(0, -1).lastIndexOf('/') + 1);
|
||||||
|
const to = `${parent}${name}/`;
|
||||||
|
if (to === from) return { prefix: to, moved: 0 };
|
||||||
|
const s3 = await renamer(p.user);
|
||||||
|
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `a folder "${name}" already exists here`);
|
||||||
|
return { prefix: to, moved: await s3.renamePrefix(p.bucket, from, to) };
|
||||||
|
});
|
||||||
|
// A drive: a new bucket gets every file and the same access, then the old
|
||||||
|
// one goes. S3 apps that use the old name need the new one.
|
||||||
|
route('POST', '/api/s3/:user/buckets/:bucket/rename', async (req, url, p) => {
|
||||||
|
const { name } = await jsonBody(req);
|
||||||
|
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(String(name || ''))) throw new HttpError(400, 'drive names are 3-63 lowercase letters, digits, dots and hyphens');
|
||||||
|
if (name === p.bucket) return { name };
|
||||||
|
const s3 = await renamer(p.user);
|
||||||
|
if ((await s3.listBuckets()).some((b) => b.name === name)) throw new HttpError(409, `a drive "${name}" already exists`);
|
||||||
|
await s3.createBucket(name);
|
||||||
|
// Public access carries over, rebuilt for the new name (a policy names its bucket).
|
||||||
|
const mode = describePolicy(await s3.getPolicy(p.bucket).catch(() => null));
|
||||||
|
const moved = await s3.renamePrefix(p.bucket, '', '', name);
|
||||||
|
if (mode === 'read' || mode === 'read-list') await s3.putPolicy(name, publicReadPolicy(name, { list: mode === 'read-list' }));
|
||||||
|
await s3.deleteBucket(p.bucket);
|
||||||
|
return { name, moved };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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 };
|
||||||
|
route('GET', '/api/space', async () => {
|
||||||
|
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
|
||||||
|
if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value;
|
||||||
|
try {
|
||||||
|
const s = await accountSpace(cfg().directory);
|
||||||
|
spaceCache = { at: Date.now(), value: s ? { available: true, ...s } : { available: false, reason: 'not connected' } };
|
||||||
|
} catch (e) {
|
||||||
|
spaceCache = { at: Date.now() - 45_000, value: { available: false, reason: e.message } };
|
||||||
|
}
|
||||||
|
return spaceCache.value;
|
||||||
|
});
|
||||||
|
|
||||||
// S3 has no folders, only names with slashes. An empty object named
|
// S3 has no folders, only names with slashes. An empty object named
|
||||||
// "<folder>/" is the usual marker that keeps an empty folder visible.
|
// "<folder>/" is the usual marker that keeps an empty folder visible.
|
||||||
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {
|
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {
|
||||||
|
|
@ -418,6 +489,68 @@ export async function createPithos(opts = {}) {
|
||||||
return { prefix };
|
return { prefix };
|
||||||
});
|
});
|
||||||
// Small text files for the viewer (first 512 KiB).
|
// Small text files for the viewer (first 512 KiB).
|
||||||
|
// HTML opened as a page. A ticket covers the folder the page sits in, so
|
||||||
|
// its relative CSS, images, scripts and links resolve; it lasts an hour from
|
||||||
|
// the last use (12 hours at most). See servePage for the sandbox.
|
||||||
|
const pages = new Map(); // id -> { user, bucket, prefix, exp, until }
|
||||||
|
route('POST', '/api/s3/:user/buckets/:bucket/page', async (req, url, p) => {
|
||||||
|
const { key } = await jsonBody(req);
|
||||||
|
if (typeof key !== 'string' || !/\.html?$/i.test(key)) throw new HttpError(400, 'only .html files open as pages');
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of pages) if (v.exp < now) pages.delete(k);
|
||||||
|
if (pages.size >= 200) throw new HttpError(429, 'too many pages open; close some and try again');
|
||||||
|
const cut = key.lastIndexOf('/') + 1;
|
||||||
|
const id = crypto.randomBytes(24).toString('base64url');
|
||||||
|
pages.set(id, { user: p.user, bucket: p.bucket, prefix: key.slice(0, cut), exp: now + 60 * 60_000, until: now + 12 * 3600_000 });
|
||||||
|
return { path: `/page/${id}/${key.slice(cut).split('/').map(encodeURIComponent).join('/')}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page and its neighbours. The sandbox CSP (no allow-same-origin) gives
|
||||||
|
// the page an opaque origin: its scripts run, but requests they make to this
|
||||||
|
// server are cross-site, so they carry no session cookie and cannot pass
|
||||||
|
// the x-pithos check. They can only read files the ticket covers.
|
||||||
|
// The page loads whatever it links to (it is the user's own site); the
|
||||||
|
// sandbox is the boundary, not a source list.
|
||||||
|
const PAGE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads; frame-ancestors 'self'";
|
||||||
|
async function servePage(req, res, id, rest) {
|
||||||
|
const t = pages.get(id);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!t || t.exp < now) { res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8' }); return res.end('This page link has expired. Open the file from Pithos again.'); }
|
||||||
|
t.exp = Math.min(now + 60 * 60_000, t.until);
|
||||||
|
let segs;
|
||||||
|
try { segs = rest.split('/').map((x) => decodeURIComponent(x)); } catch { res.writeHead(400); return res.end('bad path'); }
|
||||||
|
if (!segs.length || segs[segs.length - 1] === '') segs[segs.length ? segs.length - 1 : 0] = 'index.html';
|
||||||
|
// An encoded slash would make one "segment" span folders (..%2F..).
|
||||||
|
if (segs.some((x) => !x || x === '.' || x === '..' || /[/\\]/.test(x))) { res.writeHead(400); return res.end('bad path'); }
|
||||||
|
const key = t.prefix + segs.join('/');
|
||||||
|
const up = await (await s3For(t.user)).getObject(t.bucket, key, { range: req.headers.range });
|
||||||
|
if (up.statusCode >= 300 && up.statusCode !== 304) {
|
||||||
|
up.resume?.();
|
||||||
|
res.writeHead(up.statusCode === 416 ? 416 : 404, { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': PAGE_CSP });
|
||||||
|
return res.end(up.statusCode === 416 ? 'range not satisfiable' : 'not found');
|
||||||
|
}
|
||||||
|
// The extension decides for web files: S3 clients often store HTML, CSS or
|
||||||
|
// scripts as octet-stream (or anything), and a page must load them as such.
|
||||||
|
const guessed = guessType(key);
|
||||||
|
const stored = up.headers['content-type'];
|
||||||
|
let type = guessed !== 'application/octet-stream' ? guessed : (stored || guessed);
|
||||||
|
if (/^text\/|javascript|json|xml|svg/i.test(type) && !/charset=/i.test(type)) type += '; charset=utf-8';
|
||||||
|
const headers = {
|
||||||
|
'content-type': type,
|
||||||
|
'content-security-policy': PAGE_CSP,
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
// The sandboxed page is cross-origin even to itself; its scripts may read
|
||||||
|
// the files its ticket covers (the ticket in the URL is the secret).
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
};
|
||||||
|
for (const h of ['content-length', 'content-range', 'accept-ranges', 'last-modified', 'etag']) if (up.headers[h]) headers[h] = up.headers[h];
|
||||||
|
res.writeHead(up.statusCode, headers);
|
||||||
|
if (req.method === 'HEAD') { up.resume?.(); return res.end(); }
|
||||||
|
up.on('error', () => res.destroy());
|
||||||
|
up.pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => {
|
route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => {
|
||||||
const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' });
|
const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' });
|
||||||
const body = await readBody(up);
|
const body = await readBody(up);
|
||||||
|
|
@ -536,8 +669,18 @@ export async function createPithos(opts = {}) {
|
||||||
url = new URL(t.path, url);
|
url = new URL(t.path, url);
|
||||||
viaTicket = true;
|
viaTicket = true;
|
||||||
}
|
}
|
||||||
res.setHeader('x-frame-options', 'DENY');
|
|
||||||
res.setHeader('referrer-policy', 'no-referrer');
|
res.setHeader('referrer-policy', 'no-referrer');
|
||||||
|
const page = /^\/page\/([A-Za-z0-9_-]{20,})\/(.*)$/.exec(url.pathname);
|
||||||
|
if (page && (req.method === 'GET' || req.method === 'HEAD')) {
|
||||||
|
// A page may frame its own folder's files; nothing else frames Pithos.
|
||||||
|
res.setHeader('x-frame-options', 'SAMEORIGIN');
|
||||||
|
try { return await servePage(req, res, page[1], page[2]); } catch (e) {
|
||||||
|
if (res.headersSent) return res.destroy();
|
||||||
|
res.writeHead(e instanceof S3Error && e.status < 500 ? e.status : 502, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
return res.end(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.setHeader('x-frame-options', 'DENY');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.
|
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.
|
||||||
|
|
|
||||||
29
bundled-addons/pithos/core/space.js
Normal file
29
bundled-addons/pithos/core/space.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Free space on the Sia account behind this s3d, as the indexer reports it.
|
||||||
|
// The key reading, BLAKE2b-256 and request signing live in one place
|
||||||
|
// (sia-account.js, blake2b.js); this keeps the names the space route and its
|
||||||
|
// tests use.
|
||||||
|
|
||||||
|
import { readConnection, signRequest, accountInfo } from './sia-account.js';
|
||||||
|
|
||||||
|
export { blake2b256 } from './blake2b.js';
|
||||||
|
|
||||||
|
export const signIndexerUrl = (appKey, method, endpointUrl, validUntilSec) => signRequest(appKey, method, endpointUrl, validUntilSec);
|
||||||
|
|
||||||
|
export async function readAppKey(dataDir) {
|
||||||
|
const c = await readConnection(dataDir).catch(() => null);
|
||||||
|
return c?.appKey && c.indexerUrl ? { appKey: c.appKey, indexerUrl: c.indexerUrl } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// { maxPinnedData, pinnedData, remainingStorage, indexer } in bytes, or null
|
||||||
|
// when this s3d is not connected. Throws when the indexer does not answer.
|
||||||
|
export async function accountSpace(dataDir, { fetchImpl = fetch } = {}) {
|
||||||
|
const a = await accountInfo(dataDir, { fetchImpl, timeoutMs: 15_000 });
|
||||||
|
if (!a.connected) return null;
|
||||||
|
if (a.error) throw new Error(a.error);
|
||||||
|
return {
|
||||||
|
maxPinnedData: Number(a.maxPinnedData),
|
||||||
|
pinnedData: Number(a.pinnedData),
|
||||||
|
remainingStorage: Number(a.remainingStorage),
|
||||||
|
indexer: new URL(a.indexerUrl).host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -182,6 +182,15 @@ module.exports = {
|
||||||
} });
|
} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An HTML file from a drive, opened as a page in a new Theseus tab. The
|
||||||
|
// control server sandboxes it (no access to Pithos or the session).
|
||||||
|
api.onMessage("open-page", async ({ path: pagePath } = {}) => {
|
||||||
|
if (!/^\/page\/[A-Za-z0-9_-]{20,}\/[^\s]*$/.test(String(pagePath || ""))) throw new Error("not a page link");
|
||||||
|
const p = await ensureServer();
|
||||||
|
api.openTab(new URL(pagePath, p.url).href);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
// Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab.
|
// Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab.
|
||||||
// An older Theseus without site routes gets the loopback address.
|
// An older Theseus without site routes gets the loopback address.
|
||||||
api.onMessage("open-in-tab", async ({ path: appPath } = {}) => {
|
api.onMessage("open-in-tab", async ({ path: appPath } = {}) => {
|
||||||
|
|
|
||||||
|
|
@ -416,3 +416,8 @@ body.has-foot.has-player .toasts { bottom: 112px; }
|
||||||
/* "More options" disclosure in dialogs */
|
/* "More options" disclosure in dialogs */
|
||||||
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
||||||
details.more-opts > summary:hover { color: var(--text); }
|
details.more-opts > summary:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* Free space on the Sia account */
|
||||||
|
.space { margin-top: 6px; max-width: 420px; }
|
||||||
|
.space-bar { margin-top: 4px; height: 6px; }
|
||||||
|
.space-bar.full > i { background: var(--warn); }
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ function overview(main) {
|
||||||
put(daemonCard,
|
put(daemonCard,
|
||||||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||||||
h('dl', { class: 'kv' },
|
h('dl', { class: 'kv' },
|
||||||
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')),
|
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect'), spaceLine()),
|
||||||
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
|
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
|
||||||
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
|
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
|
||||||
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
|
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
|
||||||
|
|
@ -1194,6 +1194,7 @@ function fileKind(key) {
|
||||||
if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text';
|
if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const isHtml = (key) => ['html', 'htm'].includes(EXT(key));
|
||||||
function suggestBucket(name) {
|
function suggestBucket(name) {
|
||||||
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
if (b && b.length < 3) b += '-files';
|
if (b && b.length < 3) b += '-files';
|
||||||
|
|
@ -1281,6 +1282,19 @@ function siaAccountBlock(a, onRefresh) {
|
||||||
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
|
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space on the Sia account: "12.4 GiB of 46.6 GiB used · 34.2 GiB free" and a bar.
|
||||||
|
function spaceLine() {
|
||||||
|
const el = h('div', { class: 'space', hidden: true });
|
||||||
|
api('GET', '/api/space').then((s) => {
|
||||||
|
if (!s?.available || !(s.maxPinnedData > 0)) return;
|
||||||
|
const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100));
|
||||||
|
put(el, h('div', { class: 'small muted' }, `${fmtBytes(s.pinnedData)} of ${fmtBytes(s.maxPinnedData)} used on Sia · `, h('strong', {}, `${fmtBytes(s.remainingStorage)} free`)),
|
||||||
|
h('div', { class: `bar space-bar${pct >= 90 ? ' full' : ''}`, title: `${pct}% used` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 1 : 0)}%` })));
|
||||||
|
el.hidden = false;
|
||||||
|
}).catch(() => {});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
// "Sia account: …" line for page headers; fills itself in.
|
// "Sia account: …" line for page headers; fills itself in.
|
||||||
function accountLine() {
|
function accountLine() {
|
||||||
const el = h('div', { class: 'small muted account-line' }, 'Sia account: …');
|
const el = h('div', { class: 'small muted account-line' }, 'Sia account: …');
|
||||||
|
|
@ -1293,7 +1307,7 @@ function accountLine() {
|
||||||
|
|
||||||
async function bucketList(head, body, user, userSel) {
|
async function bucketList(head, body, user, userSel) {
|
||||||
put(head,
|
put(head,
|
||||||
h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()),
|
h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine(), spaceLine()),
|
||||||
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
|
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
|
||||||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||||||
const bar = h('div', { class: 'view-bar' });
|
const bar = h('div', { class: 'view-bar' });
|
||||||
|
|
@ -1314,7 +1328,7 @@ async function bucketList(head, body, user, userSel) {
|
||||||
put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null);
|
put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null);
|
||||||
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; }
|
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; }
|
||||||
const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`);
|
const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`);
|
||||||
const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Delete', () => remove(b.name), 'danger']]);
|
const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Rename…', () => renameDrive(b.name)], ['Delete', () => remove(b.name), 'danger']]);
|
||||||
if (mode === 'details') {
|
if (mode === 'details') {
|
||||||
put(card, h('table', { class: 'files' },
|
put(card, h('table', { class: 'files' },
|
||||||
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))),
|
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))),
|
||||||
|
|
@ -1364,6 +1378,17 @@ async function bucketList(head, body, user, userSel) {
|
||||||
try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
|
try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renameDrive(current) {
|
||||||
|
const input = h('input', { type: 'text', required: true, value: current, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', spellcheck: 'false' });
|
||||||
|
const name = await modal('Rename drive', h('div', { class: 'stack' },
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'New name'), input, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens.')),
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Files and access settings move to the new name; nothing is uploaded again. S3 apps that use this drive need the new name.')),
|
||||||
|
[{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]);
|
||||||
|
if (!name || name === current) return;
|
||||||
|
try { const r = await api('POST', `${base}/${encodeURIComponent(current)}/rename`, { name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} file${r.moved === 1 ? '' : 's'})` : ''}`); load(); }
|
||||||
|
catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(name) {
|
async function remove(name) {
|
||||||
if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return;
|
if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return;
|
||||||
try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
|
try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
|
||||||
|
|
@ -1452,8 +1477,8 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
}
|
}
|
||||||
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
|
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
|
||||||
const fileName = (o) => o.key.slice(prefix.length);
|
const fileName = (o) => o.key.slice(prefix.length);
|
||||||
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
||||||
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), '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 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();
|
const stop = (e) => e.stopPropagation();
|
||||||
// Narrow screens: Download and Share as icons beside the ⋯ menu.
|
// Narrow screens: Download and Share as icons beside the ⋯ menu.
|
||||||
|
|
@ -1613,7 +1638,20 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
} else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.'));
|
} else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.'));
|
||||||
} catch (e) { return fail(e); }
|
} catch (e) { return fail(e); }
|
||||||
await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)),
|
await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)),
|
||||||
[{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]);
|
[isHtml(o.key) && { label: 'Open as page ↗', value: () => { openPage(o.key); return false; } },
|
||||||
|
{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }].filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
// An HTML file as a web page, in a new tab. The server sandboxes it, so its
|
||||||
|
// scripts run without any access to Pithos; files beside it (CSS, images,
|
||||||
|
// other pages) load as they would from a website.
|
||||||
|
async function openPage(key) {
|
||||||
|
try {
|
||||||
|
const { path } = await api('POST', `${base}/page`, { key });
|
||||||
|
if (bridge) { await bridge.invoke('open-page', { path }); return; }
|
||||||
|
const w = window.open(path, '_blank', 'noopener');
|
||||||
|
if (!w && !/^(desktop)$/.test(state.status?.host)) toast('Allow pop-ups for Pithos to open pages in a new tab', 'error');
|
||||||
|
} catch (e) { fail(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Music plays in the docked player, on through this folder's other tracks.
|
// Music plays in the docked player, on through this folder's other tracks.
|
||||||
|
|
@ -1627,6 +1665,25 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key)));
|
player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename a file or folder in place (a server-side copy: quick, no new upload).
|
||||||
|
async function renameItem(key) {
|
||||||
|
const folder = key.endsWith('/');
|
||||||
|
const current = folder ? key.slice(0, -1).split('/').pop() : key.split('/').pop();
|
||||||
|
const input = h('input', { type: 'text', required: true, value: current, pattern: '[^/]+', spellcheck: 'false' });
|
||||||
|
// Select the name without its extension, as file managers do.
|
||||||
|
setTimeout(() => { if (!folder && current.includes('.')) input.setSelectionRange(0, current.lastIndexOf('.')); else input.select(); }, 30);
|
||||||
|
const name = await modal(folder ? 'Rename folder' : 'Rename file', h('div', { class: 'stack' },
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'New name'), input),
|
||||||
|
folder && h('p', { class: 'small muted', style: 'margin:0' }, 'Everything inside moves with it. This is quick: the files stay where they are on Sia.')),
|
||||||
|
[{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]);
|
||||||
|
if (!name || name === current) return;
|
||||||
|
try {
|
||||||
|
if (folder) { const r = await api('POST', `${base}/rename-prefix`, { from: key, name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} item${r.moved === 1 ? '' : 's'})` : ''}`); }
|
||||||
|
else { await api('POST', `${base}/rename-object`, { from: key, name }); toast(`Renamed to ${name}`); }
|
||||||
|
load();
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteObject(key) {
|
async function deleteObject(key) {
|
||||||
if (!(await confirmModal('Delete object?', key))) return;
|
if (!(await confirmModal('Delete object?', key))) return;
|
||||||
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }
|
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue