2026-10-03 22:57:47 +02:00
|
|
|
// Switching the Sia account an s3d is connected to. s3d has no "log out":
|
|
|
|
|
// its connection (the app key), users, keys, buckets and upload buffer all
|
|
|
|
|
// live in its data folder. A switch moves that folder's contents into
|
|
|
|
|
// previous-account-<stamp>/ beside it (never deleted), so s3d starts fresh
|
|
|
|
|
// and can be connected to another account; a restore swaps one back.
|
|
|
|
|
//
|
|
|
|
|
// s3d.yml stays where it is: it holds addresses and the admin password, not
|
|
|
|
|
// the account. pithos.json (the user's label for the account) moves with the
|
|
|
|
|
// data, because the label belongs to that account.
|
|
|
|
|
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
|
|
|
|
const ARCHIVE_PREFIX = 'previous-account-';
|
|
|
|
|
const META = 'pithos-archive.json';
|
|
|
|
|
const LABEL_FILE = 'pithos.json';
|
|
|
|
|
|
2026-10-04 03:36:50 +02:00
|
|
|
// Files that belong to this computer, not to a Sia account. The hosted key
|
|
|
|
|
// file above all: moving or deleting it would make encrypted files unreadable.
|
|
|
|
|
const LOCAL = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json']);
|
|
|
|
|
const keep = (name) => LOCAL.has(name) || name.startsWith('pithos-hosted-secret') || name.startsWith(ARCHIVE_PREFIX);
|
2026-10-03 22:57:47 +02:00
|
|
|
|
|
|
|
|
function stamp(d = new Date()) {
|
|
|
|
|
const p = (n) => String(n).padStart(2, '0');
|
|
|
|
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readLabel(dataDir) {
|
|
|
|
|
try { return String(JSON.parse(fs.readFileSync(path.join(dataDir, LABEL_FILE), 'utf8')).accountLabel || ''); } catch { return ''; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function writeLabel(dataDir, label) {
|
|
|
|
|
const clean = String(label || '').trim().slice(0, 120);
|
|
|
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
|
|
|
let cur = {};
|
|
|
|
|
try { cur = JSON.parse(fs.readFileSync(path.join(dataDir, LABEL_FILE), 'utf8')); } catch {}
|
|
|
|
|
fs.writeFileSync(path.join(dataDir, LABEL_FILE), JSON.stringify({ ...cur, accountLabel: clean }, null, 2));
|
|
|
|
|
return clean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listArchives(dataDir) {
|
|
|
|
|
let names = [];
|
|
|
|
|
try { names = fs.readdirSync(dataDir).filter((n) => n.startsWith(ARCHIVE_PREFIX)); } catch { return []; }
|
|
|
|
|
return names.sort().reverse().map((name) => {
|
|
|
|
|
const dir = path.join(dataDir, name);
|
|
|
|
|
let meta = {};
|
|
|
|
|
try { meta = JSON.parse(fs.readFileSync(path.join(dir, META), 'utf8')); } catch {}
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
archivedAt: meta.archivedAt || null,
|
|
|
|
|
indexerUrl: meta.indexerUrl || null,
|
|
|
|
|
label: meta.label || readLabel(dir),
|
|
|
|
|
users: meta.users || [],
|
2026-10-04 03:36:50 +02:00
|
|
|
movedTo: meta.movedTo || null,
|
2026-10-03 22:57:47 +02:00
|
|
|
hasData: fs.existsSync(path.join(dir, 's3d.db')),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Moves the current account's data aside. `meta` is stored with it for the
|
|
|
|
|
// restore list: { indexerUrl, users }.
|
|
|
|
|
export function archiveCurrent(dataDir, meta = {}) {
|
|
|
|
|
if (!fs.existsSync(path.join(dataDir, 's3d.db'))) throw new Error('there is no s3d data to move aside');
|
|
|
|
|
let name = ARCHIVE_PREFIX + stamp();
|
|
|
|
|
while (fs.existsSync(path.join(dataDir, name))) name += '-1';
|
|
|
|
|
const dest = path.join(dataDir, name);
|
|
|
|
|
fs.mkdirSync(dest);
|
|
|
|
|
const label = readLabel(dataDir);
|
|
|
|
|
for (const entry of fs.readdirSync(dataDir)) {
|
|
|
|
|
if (keep(entry) || entry === name) continue;
|
|
|
|
|
fs.renameSync(path.join(dataDir, entry), path.join(dest, entry));
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(path.join(dest, META), JSON.stringify({ archivedAt: new Date().toISOString(), label, ...meta }, null, 2));
|
|
|
|
|
return { name, path: dest };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Swaps an archived account back in. Whatever is current is archived first
|
|
|
|
|
// (when there is anything), so a restore never loses data either.
|
|
|
|
|
export function restoreArchive(dataDir, name, metaForCurrent = {}) {
|
|
|
|
|
if (!name.startsWith(ARCHIVE_PREFIX) || name.includes('/') || name.includes('\\') || name.includes('..')) throw new Error('not an account archive');
|
|
|
|
|
const src = path.join(dataDir, name);
|
|
|
|
|
if (!fs.existsSync(path.join(src, 's3d.db'))) throw new Error('that archive has no s3d data');
|
2026-10-04 03:36:50 +02:00
|
|
|
let meta = {};
|
|
|
|
|
try { meta = JSON.parse(fs.readFileSync(path.join(src, META), 'utf8')); } catch {}
|
|
|
|
|
// The copy left behind when drives moved to Silent Mode is out of date the
|
|
|
|
|
// moment the server s3d writes anything; running it would fork the account.
|
|
|
|
|
if (meta.movedTo) throw new Error(`these drives now live on ${meta.movedTo}; move them back from Settings instead`);
|
2026-10-03 22:57:47 +02:00
|
|
|
const archivedCurrent = fs.existsSync(path.join(dataDir, 's3d.db')) ? archiveCurrent(dataDir, metaForCurrent).name : null;
|
|
|
|
|
// Anything left (tmp folders from a fresh, unconnected s3d) is clutter.
|
|
|
|
|
for (const entry of fs.readdirSync(dataDir)) {
|
|
|
|
|
if (keep(entry)) continue;
|
|
|
|
|
fs.rmSync(path.join(dataDir, entry), { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
for (const entry of fs.readdirSync(src)) {
|
|
|
|
|
if (entry === META) continue;
|
|
|
|
|
fs.renameSync(path.join(src, entry), path.join(dataDir, entry));
|
|
|
|
|
}
|
|
|
|
|
fs.rmSync(src, { recursive: true, force: true });
|
|
|
|
|
return { restored: name, archivedCurrent };
|
|
|
|
|
}
|