prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell-fs.js (7212B)


      1 // Writable filesystem for the web shell. The server's read-only tree
      2 // (/api/fs, generated from site content) is the base layer; whatever the
      3 // visitor creates, edits or deletes is kept as an overlay in localStorage and
      4 // replayed on top of it, so their files survive reloads and new site content
      5 // still shows up underneath.
      6 //
      7 // Overlay entries are keyed by absolute path:
      8 //   null              deleted (whiteout over a base node)
      9 //   {d:1, m, f?:1}    directory; f = fresh, replaces whatever the base had
     10 //   {c, m}            file with content c
     11 // m is the mtime in ms.
     12 
     13 const STORE_KEY = "prime8.fs.v1";
     14 const QUOTA = 1 << 20; // chars of serialized overlay, well under localStorage limits
     15 
     16 export const enc = new TextEncoder();
     17 
     18 // --- paths (same rules as internal/vfs) ---
     19 
     20 export function cleanPath(p) {
     21   const out = [];
     22   for (const part of p.split("/")) {
     23     if (part === "" || part === ".") continue;
     24     if (part === "..") out.pop();
     25     else out.push(part);
     26   }
     27   return "/" + out.join("/");
     28 }
     29 
     30 export function resolvePath(home, cwd, p) {
     31   if (p === "" || p === "~") return home;
     32   if (p.startsWith("~/")) p = home + p.slice(1);
     33   else if (!p.startsWith("/")) p = cwd + "/" + p;
     34   return cleanPath(p);
     35 }
     36 
     37 export function displayPath(home, abs) {
     38   if (abs === home) return "~";
     39   if (abs.startsWith(home + "/")) return "~" + abs.slice(home.length);
     40   return abs;
     41 }
     42 
     43 export function dirname(abs) {
     44   const i = abs.lastIndexOf("/");
     45   return i <= 0 ? "/" : abs.slice(0, i);
     46 }
     47 
     48 export function basename(abs) {
     49   return abs.slice(abs.lastIndexOf("/") + 1);
     50 }
     51 
     52 export function inside(abs, base) {
     53   return abs === base || abs.startsWith(base === "/" ? "/" : base + "/");
     54 }
     55 
     56 export function lookup(root, abs) {
     57   let n = root;
     58   for (const part of abs.split("/")) {
     59     if (part === "") continue;
     60     if (!n.dir) return null;
     61     const c = n.children[part];
     62     if (!c) return null;
     63     n = c;
     64   }
     65   return n;
     66 }
     67 
     68 export function sortedChildren(n) {
     69   return Object.values(n.children || {}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
     70 }
     71 
     72 export function nodeSize(n) {
     73   return n.dir ? 4096 : enc.encode(n.content).length;
     74 }
     75 
     76 function mkDir(name, mtime) {
     77   return { name, dir: true, children: {}, mtime };
     78 }
     79 
     80 function mkFile(name, content, mtime) {
     81   return { name, dir: false, content, mtime };
     82 }
     83 
     84 function cloneBase(n, mtime) {
     85   if (!n.dir) return mkFile(n.name, n.content || "", mtime);
     86   const d = mkDir(n.name, mtime);
     87   for (const [k, c] of Object.entries(n.children || {})) d.children[k] = cloneBase(c, mtime);
     88   return d;
     89 }
     90 
     91 function depth(p) {
     92   return p.split("/").length;
     93 }
     94 
     95 function applyOverlay(root, entries) {
     96   const keys = Object.keys(entries).sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : 1));
     97   for (const k of keys) {
     98     const parent = lookup(root, dirname(k));
     99     if (!parent || !parent.dir) continue;
    100     const name = basename(k);
    101     const v = entries[k];
    102     if (v === null) delete parent.children[name];
    103     else if (v.d) {
    104       const cur = parent.children[name];
    105       if (v.f || !cur || !cur.dir) parent.children[name] = mkDir(name, v.m);
    106       else cur.mtime = v.m;
    107     } else parent.children[name] = mkFile(name, v.c, v.m);
    108   }
    109 }
    110 
    111 export class VFS {
    112   constructor(data) {
    113     this.base = data.root;
    114     this.user = data.user;
    115     this.home = data.home;
    116     const t = new Date();
    117     t.setHours(0, 0, 0, 0);
    118     this.baseTime = t.getTime();
    119     this.load();
    120   }
    121 
    122   load() {
    123     let saved = "";
    124     try {
    125       saved = window.localStorage.getItem(STORE_KEY) || "";
    126     } catch {
    127       saved = "";
    128     }
    129     this.saved = saved;
    130     this.entries = {};
    131     try {
    132       const parsed = saved ? JSON.parse(saved) : null;
    133       if (parsed && typeof parsed.e === "object" && parsed.e) this.entries = parsed.e;
    134     } catch {
    135       this.entries = {};
    136     }
    137     this.dirty = false;
    138     this.rebuild();
    139   }
    140 
    141   rebuild() {
    142     this.root = cloneBase(this.base, this.baseTime);
    143     applyOverlay(this.root, this.entries);
    144   }
    145 
    146   changes() {
    147     return Object.keys(this.entries).length;
    148   }
    149 
    150   // commit persists the overlay. On quota failure it rolls back to the last
    151   // saved state and returns false.
    152   commit() {
    153     if (!this.dirty) return true;
    154     this.dirty = false;
    155     const s = JSON.stringify({ v: 1, e: this.entries });
    156     let ok = s.length <= QUOTA;
    157     if (ok) {
    158       try {
    159         window.localStorage.setItem(STORE_KEY, s);
    160       } catch {
    161         ok = false;
    162       }
    163     }
    164     if (ok) {
    165       this.saved = s;
    166       return true;
    167     }
    168     this.load();
    169     return false;
    170   }
    171 
    172   reset() {
    173     this.entries = {};
    174     try {
    175       window.localStorage.removeItem(STORE_KEY);
    176     } catch {
    177       /* ignore */
    178     }
    179     this.saved = "";
    180     this.dirty = false;
    181     this.rebuild();
    182   }
    183 
    184   // writable: the visitor owns their home and /tmp; everything else is root's.
    185   writable(abs) {
    186     return inside(abs, this.home) || inside(abs, "/tmp");
    187   }
    188 
    189   owner(abs) {
    190     return inside(abs, this.home) ? this.user : "root";
    191   }
    192 
    193   inBase(abs) {
    194     return lookup(this.base, abs) !== null;
    195   }
    196 
    197   get(abs) {
    198     return lookup(this.root, abs);
    199   }
    200 
    201   // checkCreate reports why abs cannot be created/replaced/removed, or "".
    202   checkCreate(abs) {
    203     if (abs === "/") return "Permission denied";
    204     const parent = this.get(dirname(abs));
    205     if (!parent) return "No such file or directory";
    206     if (!parent.dir) return "Not a directory";
    207     if (!this.writable(dirname(abs)) || !this.writable(abs)) return "Permission denied";
    208     return "";
    209   }
    210 
    211   mkdir(abs) {
    212     const parent = this.get(dirname(abs));
    213     const name = basename(abs);
    214     const t = Date.now();
    215     parent.children[name] = mkDir(name, t);
    216     parent.mtime = t;
    217     this.entries[abs] = this.inBase(abs) ? { d: 1, m: t, f: 1 } : { d: 1, m: t };
    218     this.dirty = true;
    219   }
    220 
    221   write(abs, content) {
    222     const parent = this.get(dirname(abs));
    223     const name = basename(abs);
    224     const t = Date.now();
    225     if (!parent.children[name]) parent.mtime = t;
    226     parent.children[name] = mkFile(name, content, t);
    227     this.entries[abs] = { c: content, m: t };
    228     this.dirty = true;
    229   }
    230 
    231   touch(abs) {
    232     const n = this.get(abs);
    233     const t = Date.now();
    234     n.mtime = t;
    235     if (!n.dir) this.entries[abs] = { c: n.content, m: t };
    236     else if (this.entries[abs]) this.entries[abs].m = t;
    237     this.dirty = true;
    238   }
    239 
    240   remove(abs) {
    241     const parent = this.get(dirname(abs));
    242     delete parent.children[basename(abs)];
    243     parent.mtime = Date.now();
    244     const prefix = abs + "/";
    245     for (const k of Object.keys(this.entries)) if (k.startsWith(prefix)) delete this.entries[k];
    246     if (this.inBase(abs)) this.entries[abs] = null;
    247     else delete this.entries[abs];
    248     this.dirty = true;
    249   }
    250 
    251   // copy materializes node (a whole subtree for a directory) at abs.
    252   copy(node, abs) {
    253     if (this.get(abs)) this.remove(abs);
    254     if (!node.dir) {
    255       this.write(abs, node.content);
    256       return;
    257     }
    258     this.mkdir(abs);
    259     for (const c of sortedChildren(node)) this.copy(c, abs + "/" + c.name);
    260   }
    261 
    262   count(n = this.root) {
    263     if (!n.dir) return 1;
    264     let total = 1;
    265     for (const c of Object.values(n.children)) total += this.count(c);
    266     return total;
    267   }
    268 }
    269 
    270 export { STORE_KEY };