🍉 Sync 2026-09-25 18:00:12

This commit is contained in:
github-actions[bot] committed 2026-09-25 18:00:12 +08:00
1 parent 7c17dda4d0
commit 2ee65a261e
135 files changed
+5726 -7799

No files matched your search

@@ -2,91 +2,12 @@
'require view';
'require rpc';
'require ui';
'require fs';
'require view.gitbackup.common as common';
/* global common */
// gitbackup -- Paths (ticket 13, spec "Решения по реализации -> LuCI -> Paths",
// "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade"). Client-side
// JS only, luci-base only -- same constraint and the same jsmin-conservative
// style (plain ES5, `var`/`function`, no let/const/arrow/template literals,
// every statement semicolon-terminated) as overview.js (ticket 11), read
// before writing this file.
//
// The whole point of this screen (spec/brief, verbatim): "Расширение списка --
// редактирование /etc/sysupgrade.conf, а не отдельный UCI-список. То, что
// пользователь добавил ради бэкапа, автоматически переживёт реальный
// sysupgrade." That file is owned entirely by package/gitbackup's paths.sh and
// this file's own rpcd bridge (usr/libexec/rpcd/luci.gitbackup) -- both out of
// this ticket's own zone in the original cut, but the read/write asymmetry
// documented below was later closed in that same package/gitbackup zone, and
// this view was updated alongside it.
//
// ---------------------------------------------------------------------
// Raw vs. entries vs. effective -- the defect this file used to work
// around instead of fixing, closed by making `list_paths` carry three
// sets explicitly:
//
// - `paths` -- the RAW, unexpanded content of /etc/sysupgrade.conf
// (gb_paths_list), comments and blank lines included.
// Not used by this view at all (see `entries` below).
// - `entries` -- (ticket 26) `paths`, filtered down to genuine path
// entries: comments (sysupgrade.conf's own syntax --
// a line starting with '#', including a commented-out
// EXAMPLE such as "# /etc/openvpn/", which backs up
// nothing) and blank lines are dropped by
// gb_paths_entries server-side. This is what this view
// lists and edits, and the only thing `set_paths` ever
// writes back -- a directory line stays a directory
// line across any number of edits, so anything added
// to it later keeps being covered by a real sysupgrade
// the same way it always was, instead of being frozen
// into the exact file list that happened to exist on
// the day of the last save here. Before this field
// existed, this view built its editable list straight
// from `paths`, so a stock router's own header
// comments and example lines rendered as four bogus,
// removable entries -- and the first save from this
// view (any Add or Remove click) silently erased them,
// because `set_paths` used to write back only what it
// was sent and never looked at the rest of the file.
// That erasure is now prevented server-side
// (gb_paths_replace_entries, paths.sh) rather than by
// this view trying to resend lines it was never shown
// in the first place -- see that function's own header
// comment for why the fix belongs there.
// - `effective` -- plain `sysupgrade -l`'s own wider union (spec:
// "(/etc/sysupgrade.conf ∪ /lib/upgrade/keep.d/*) ∪
// изменённые conffiles"), already recursively expanded
// to individual files/symlinks. Read-only: used only
// to size the real backup set (the 2 MB warning has to
// mean what `sysupgrade -b` will actually build, not
// just the lines a human typed) and to show what is
// already covered without anyone having added it here.
//
// Earlier, `list_paths` returned only the effective set under the name
// `paths`, so this view's own edits were built on top of the ALREADY
// EXPANDED list -- the first save after a human added a directory line
// via the CLI would write that directory's individual files straight
// back to sysupgrade.conf, permanently losing the one-line-per-directory
// convenience. Editing the raw set instead removes the problem instead of
// working around it.
//
// A second, related gap this same change closes: `set_paths`'s own
// server-side check used to duplicate only two of paths.sh's
// gb_paths_validate rules (absolute, the fixed blacklist), leaving a
// space in the path and a nonexistent path to this view's own JS to
// catch -- which could never reliably tell a dangling symlink (a
// legitimate entry, e.g. /etc/resolv.conf) from a genuinely missing path,
// because no rpcd ACL anywhere grants `fs.lstat` (confirmed live: every
// attempt at fs.lstat here failed with rpcd's own -32002 "Access denied",
// browser console, root login included -- only `fs.stat` is reachable).
// `set_paths` now routes every entry through gb_paths_validate itself,
// which runs ON the router and sees the real filesystem, so it gets `-e
// || -L` right without needing that ACL grant at all. This view keeps its
// own quick client-side checks (gbPathReject below) as a fast first pass
// that avoids an obviously-doomed round trip, but the round trip -- and
// its `rejected[]` response -- is the actual, authoritative gate; this
// view no longer attempts its own existence check at all.
// ---------------------------------------------------------------------
// Writes back `entries`, never `effective` (sysupgrade -l): a directory line
// must stay a directory line, or files created after the save stop being
// backed up. Path validation lives in paths.sh only.
var callListPaths = rpc.declare({
object: 'luci.gitbackup',
@@ -104,127 +25,16 @@ var callSetPaths = rpc.declare({
params: [ 'paths' ]
});
// gb_paths_size_kb (paths.sh) rounds bytes up to a whole KB: awk's
// `(s + 1023) / 1024` truncated to an integer is exactly `ceil(s / 1024)`.
// No rpcd method exposes that number directly, so this view derives the
// same total the same way paths.sh does: sum the byte size of every entry
// in `list_paths`'s "effective" set -- the same full set `sysupgrade -l`
// (and `run`'s own pre-flight space check) actually measures, not just
// the raw lines a human typed -- using `fs.stat`, deliberately not
// `fs.lstat`, and not only because of the ACL note above. gb_paths_size_kb's
// own test is plain `[ -f "$path" ]`, which -- confirmed live against this
// stand's busybox ash (a symlink to a regular file passes `-f`) -- FOLLOWS
// a symlink to its target, same as `fs.stat`; only a target that is not a
// regular file (a directory, or a dangling symlink) makes `-f` false and
// the entry contributes 0. Using `fs.lstat` here would size the symlink's
// own few bytes instead of its target's real content and disagree with
// the shell-side total for the exact same set.
var GB_PATHS_WARN_KB = 2048;
// gbPathReject <path> -- null when the path is worth submitting to
// `set_paths`, otherwise one human-readable reason, worded identically to
// package/gitbackup's paths.sh (gb_paths_validate) for the checks that
// module owns and that do not need a filesystem round trip: absolute, no
// space, not in the fixed blacklist. Order matches gb_paths_validate's own
// (absolute first, everything else assumes it). This is a fast first pass
// only -- `set_paths` re-validates every entry server-side (gb_paths_validate
// itself, including existence) and is the actual gate; a path that slips
// past this function is still caught there and reported through
// commitPaths's own `rejected[]` handling below.
function gbPathReject(path) {
if (path.charAt(0) !== '/')
return _('%s: not an absolute path').format(path);
if (path.indexOf(' ') !== -1)
return _('%s: contains a space -- sysupgrade.conf lines become find(1) arguments and cannot quote one').format(path);
if (path === '/etc/gitbackup' || path.indexOf('/etc/gitbackup/') === 0)
return _('%s: reserved for gitbackup itself').format(path);
if (path === '/proc' || path.indexOf('/proc/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/sys' || path.indexOf('/sys/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/tmp' || path.indexOf('/tmp/') === 0)
return _('%s: cleared on every reboot').format(path);
return null;
}
// gbSequentialMap <items[]> <fn> -- like items.map(fn), but runs one call at
// a time and only starts the next once the previous settles, instead of
// firing every call at once. Confirmed necessary live on the owlab stand:
// stock `uhttpd` there runs with `-n 3` (three concurrent ubus-bridge
// requests, the same limit a real low-memory router ships with, not
// something this dev stand loosened), and firing one `fs.stat` per entry of
// even the 13-line clean-image default set at once left every later ubus
// call queued behind it indefinitely -- confirmed by watching the network
// panel: no request for a later call was even sent until an earlier one's
// slot freed, and with enough of them in flight the rest of the page (this
// view's own next list_paths/set_paths call, even an unrelated view) stayed
// stuck on "Loading view..." until the batch drained. `Promise.all` over
// every path was the original, incorrect implementation.
function gbSequentialMap(items, fn) {
var results = [];
var i = 0;
function next() {
if (i >= items.length)
return Promise.resolve(results);
return fn(items[i]).then(function(res) {
results.push(res);
i++;
return next();
});
}
return next();
}
// gbSumSizes <paths[]> -- { kb, unknown }. `unknown` counts entries whose
// size could not be read at all (permission, or the entry vanished between
// `list_paths` answering and this running) so the total is never silently
// wrong without saying so. Called with the "effective" set, never the raw
// one -- see the file-header comment.
function gbSumSizes(paths) {
return gbSequentialMap(paths, function(p) {
return fs.stat(p).then(function(st) { return st; }, function() { return null; });
}).then(function(stats) {
var bytes = 0;
var unknown = 0;
var i;
for (i = 0; i < stats.length; i++) {
if (stats[i] && stats[i].type === 'file')
bytes += stats[i].size || 0;
else if (!stats[i])
unknown++;
}
return { kb: Math.ceil(bytes / 1024), unknown: unknown };
});
}
// gbCoveredBy <rawEntry> <effectivePath> -- true when <effectivePath> is
// exactly <rawEntry>, or lies underneath it as a directory. <rawEntry>'s
// own trailing slash (if any -- sysupgrade.conf directory lines commonly
// carry one) is normalized away first so both spellings of the same
// directory match the same children.
// sysupgrade.conf directory lines commonly carry a trailing slash.
function gbCoveredBy(rawEntry, effectivePath) {
var prefix = rawEntry.replace(/\/+$/, '') + '/';
return effectivePath === rawEntry || effectivePath.indexOf(prefix) === 0;
}
// gbAutomaticEntries <rawPaths[]> <effectivePaths[]> -- the "effective" set
// entries not explained by anything a human put in the raw list (not equal
// to a raw entry, not a descendant of a raw directory entry) -- i.e. only
// there because /lib/upgrade/keep.d/* or a changed package conffile
// already covers it. Shown read-only, separately from the editable raw
// list, so the operator can see the difference between "I added this" and
// "this is already covered without me doing anything".
// Effective entries no raw line explains: kept by /lib/upgrade/keep.d or a
// changed package conffile without the operator adding them.
function gbAutomaticEntries(rawPaths, effectivePaths) {
return effectivePaths.filter(function(p) {
var i;
@@ -264,21 +74,12 @@ function gbBuildPathsList(self, paths) {
}));
}
function gbBuildSizeBox(kb, unknown) {
var children = [];
var text;
function gbBuildSizeBox(kb) {
var children = [
E('p', { 'class': 'gitbackup-size' }, [ _('Total size of the backup set: %d KB').format(kb) ])
];
if (kb == null)
text = _('Calculating the backup set size…');
else
text = _('Total size of the backup set: %d KB').format(kb);
if (unknown)
text += ' ' + _('(some entries could not be measured and are left out of this total)');
children.push(E('p', { 'class': 'gitbackup-size' }, [ text ]));
if (kb != null && kb > GB_PATHS_WARN_KB) {
if (kb > GB_PATHS_WARN_KB) {
children.push(E('div', { 'class': 'gitbackup-warn' }, [
E('p', {}, [ _('This is over 2 MB. A real "sysupgrade -b" builds this archive entirely in RAM, and past this size it can fail on a low-memory device. This is only a warning -- nothing here is blocked, trim the list above if that matters on this device.') ])
]));
@@ -330,11 +131,7 @@ function gbBuildAuditBox(self, audit) {
}
var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-caption { color: var(--text-color-medium, #666); }',
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
'.gitbackup-actions input[type="text"] { flex: 1 1 260px; min-width: 200px; }',
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-path-list { list-style: none; margin: .5em 0; padding: 0; }',
'.gitbackup-path-row { display: flex; align-items: center; justify-content: space-between; gap: .75em; padding: .4em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); }',
'.gitbackup-path-row:last-child { border-bottom: none; }',
@@ -346,9 +143,6 @@ var GB_CSS = [
];
return view.extend({
// Not a config form -- see overview.js's own identical comment. Rule 11
// of the spec's style guide: the stock Save/Apply/Reset row is switched
// off through this documented hook, never hidden with CSS.
handleSaveApply: null,
handleSave: null,
handleReset: null,
@@ -360,25 +154,25 @@ return view.extend({
]);
},
applyData: function(data) {
this._paths = (data[0] && data[0].entries) || [];
this._effective = (data[0] && data[0].effective) || [];
this._sizeKb = (data[0] && data[0].size_kb) || 0;
this._audit = (data[1] && data[1].paths) || [];
},
render: function(data) {
var self = this;
var view;
self._paths = (data[0] && data[0].entries) || [];
self._effective = (data[0] && data[0].effective) || [];
self._audit = (data[1] && data[1].paths) || [];
self._sizeKb = null;
self._sizeUnknown = 0;
self.applyData(data);
self._body = E('div');
view = E('div', { 'class': 'gitbackup-view' }, [
E('style', { 'type': 'text/css' }, [ GB_CSS.join('\n') ]),
E('style', { 'type': 'text/css' }, [ common.css.concat(GB_CSS).join('\n') ]),
E('h2', {}, [ _('Git Backup - Paths') ]),
// Spec, verbatim: the caption is itself a feature, not a footnote
// -- this edits the same file the router's own firmware-upgrade
// tool reads, so anything listed here survives a real sysupgrade
// on its own, whether or not gitbackup itself ever runs again.
E('p', { 'class': 'gitbackup-caption' },
[ _('These paths also survive a firmware upgrade: they are written straight into /etc/sysupgrade.conf, the same file the router\'s own sysupgrade reads, not a separate list of this tool\'s own. A directory you add here stays a directory -- anything added to it later is covered too, exactly like a real sysupgrade would.') ]),
@@ -402,66 +196,34 @@ return view.extend({
}, [ _('Add path') ])
]),
E('div', { 'id': 'gitbackup-paths-body' })
self._body
]);
self.renderBody();
self.refreshSizes();
return view;
},
// Rebuilds the size summary, editable path list, automatic-coverage box
// and audit box from self._paths/self._effective/self._audit -- called
// after every load and every successful edit (commitPaths/refresh
// below). Small N (13 keep.d entries on a clean stand plus whatever has
// been added), so a full rebuild rather than per-row DOM patching is
// simple and cheap enough.
renderBody: function() {
var self = this;
var body = document.getElementById('gitbackup-paths-body');
var automatic;
if (!body)
return;
automatic = gbAutomaticEntries(self._paths, self._effective);
// A node reference, not getElementById: render() calls this before
// its tree is attached to the document.
var body = self._body;
var automatic = gbAutomaticEntries(self._paths, self._effective);
body.textContent = '';
body.appendChild(gbBuildSizeBox(self._sizeKb, self._sizeUnknown));
body.appendChild(gbBuildSizeBox(self._sizeKb));
body.appendChild(gbBuildPathsList(self, self._paths));
body.appendChild(gbBuildAutomaticBox(automatic));
body.appendChild(gbBuildAuditBox(self, self._audit));
},
refreshSizes: function() {
var self = this;
var effective = self._effective;
return gbSumSizes(effective).then(function(res) {
// A slower stat batch landing after the effective set moved on
// (another edit already completed) would show a total for a
// set that is no longer displayed -- discard it instead.
if (self._effective !== effective)
return;
self._sizeKb = res.kb;
self._sizeUnknown = res.unknown;
self.renderBody();
});
},
refresh: function() {
var self = this;
return Promise.all([
L.resolveDefault(callListPaths(), null),
L.resolveDefault(callAuditPaths(), null)
]).then(function(data) {
self._paths = (data[0] && data[0].entries) || [];
self._effective = (data[0] && data[0].effective) || [];
self._audit = (data[1] && data[1].paths) || [];
return self.load().then(function(data) {
self.applyData(data);
self.renderBody();
return self.refreshSizes();
});
},
@@ -475,14 +237,7 @@ return view.extend({
btn.disabled = busy;
},
// commitPaths <newList> [addedPath] -- the one path to `set_paths`.
// Reports every backend-side rejection by name and reason (spec: "не
// молча"), then always refreshes from `list_paths` regardless of outcome
// -- the effective set on disk is the only thing worth trusting after a
// write, never this view's own optimistic guess at what changed.
// Resolves to true only when `addedPath` (if given) was not among the
// rejected entries, so callers adding a single new path know whether to
// also clear the input field.
// Resolves false when addedPath itself was rejected.
commitPaths: function(newList, addedPath) {
var self = this;
@@ -492,8 +247,8 @@ return view.extend({
var i;
for (i = 0; i < rejected.length; i++) {
ui.addNotification(null, E('p', {},
[ _('%s: %s').format(rejected[i].path, rejected[i].reason) ]), 'error');
// The reason already starts with the path.
ui.addNotification(null, E('p', {}, [ rejected[i].reason ]), 'error');
if (addedPath && rejected[i].path === addedPath)
ok = false;
}
@@ -506,18 +261,10 @@ return view.extend({
});
},
// handleAddPath -- only the fast, local checks (non-empty, not already
// present, gbPathReject's syntax-only rules) happen before this ever
// calls `set_paths`. Existence is deliberately NOT checked here: it
// needs a real filesystem, `set_paths` already checks it authoritatively
// server-side (gb_paths_validate), and a client-side attempt at the same
// question would need `fs.lstat`, which no rpcd ACL here grants -- see
// the file-header comment.
handleAddPath: function(ev) {
var self = this;
var input = document.getElementById('gitbackup-paths-add-input');
var path = input ? input.value.replace(/^\s+|\s+$/g, '') : '';
var reason;
var path = input ? input.value.trim() : '';
if (!path) {
ui.addNotification(null, E('p', {}, [ _('Enter a path first.') ]), 'error');
@@ -529,12 +276,6 @@ return view.extend({
return;
}
reason = gbPathReject(path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
self.setAddBusy(true);
return self.commitPaths(self._paths.concat([ path ]), path).then(function(ok) {
@@ -544,19 +285,12 @@ return view.extend({
});
},
handleAddFromAudit: function(ev, path) {
var self = this;
var reason = gbPathReject(path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
return self.commitPaths(self._paths.concat([ path ]), path);
// createHandlerFn passes bound args first, the event last.
handleAddFromAudit: function(path, ev) {
return this.commitPaths(this._paths.concat([ path ]), path);
},
handleRemovePath: function(ev, path) {
handleRemovePath: function(path, ev) {
var self = this;
var idx = self._paths.indexOf(path);
var next;