'use strict'; 'require view'; 'require form'; 'require rpc'; 'require ui'; 'require view.gitbackup.oplog as oplog'; /* global oplog */ // See overview.js's own identical comment: the project's eslint config // (openwrt/luci's own) only knows a fixed list of stock LuCI module names // as globals, not this app's own oplog.js alias. // gitbackup -- Settings (ticket 12, spec "Решения по реализации -> LuCI -> // Settings", "UCI-схема", "Аутентификация", "Парсер remote URL", // "Расписание", "Гейт видимости"). Client-side JS only, luci-base only, // same jsmin-conservative style (plain ES5, `var`/`function`, no // let/const/arrow/template literals, every statement semicolon-terminated) // as overview.js (ticket 11), paths.js (ticket 13) and history.js (ticket // 14), all read before writing this file -- their rpcd/style/poller-cleanup // patterns are reused here, not reinvented. This is the fourth and last of // the four tabs (interfaces.md: "четыре экрана"). // // Unlike the other three views, this one IS a config form (form.Map over // the whole "gitbackup" UCI package), so the stock Save/Apply/Reset row is // left alone -- rule 11 of the spec's style guide only says stock buttons // must never be hidden with CSS, not that every view must null them out; // overview.js/paths.js/history.js null them out because none of them are // backed by a form.Map in the first place. // // --------------------------------------------------------------------- // rpcd bindings -- copied from root/usr/share/rpcd/acl.d/luci-app- // gitbackup.json and usr/libexec/rpcd/luci.gitbackup's own gbrpc_* // handlers (package/gitbackup, out of this ticket's zone). `set_secret` // and `keygen` are the two write methods no other view touches. // --------------------------------------------------------------------- var callStatus = rpc.declare({ object: 'luci.gitbackup', method: 'status' }); var callPubkey = rpc.declare({ object: 'luci.gitbackup', method: 'pubkey' }); // callKeygen([force[, confirm]]) -- ticket 25. `force` with no `confirm` // (or a `confirm` that does not match) no longer regenerates anything over // an existing key: gbrpc_keygen answers with `confirm_required: true` and // the current key's own `fingerprint` instead (gb_keygen's own "show, // then name it back" shape, auth.sh, out of this ticket's zone) -- the // same two-call round trip callHostkey below already uses for the host-key // prompt. `confirm` is only ever the exact fingerprint text a previous // call answered with. var callKeygen = rpc.declare({ object: 'luci.gitbackup', method: 'keygen', params: [ 'force', 'confirm' ] }); var callSetSecret = rpc.declare({ object: 'luci.gitbackup', method: 'set_secret', params: [ 'value' ] }); var callTest = rpc.declare({ object: 'luci.gitbackup', method: 'test' }); // callHostkey([fingerprint]) -- ticket 20. With no argument, shows the // remote's SSH host key fingerprint without recording it (gbrpc_hostkey -> // `gitbackup hostkey show`). With the exact fingerprint text `hostkey show` // printed, accepts and records it (`gitbackup hostkey accept`) -- this is // the only way this view can ever get an SSH host key trusted at all: // `callTest` above backgrounds `gitbackup test` with stdin ` -- { scheme, host, owner, repo } for one of the // three forms gb_parse_url accepts (scp-like "git@host:owner/repo.git", // "ssh://[user@]host[:port]/owner/repo(.git)?", "https://host[:port]/ // owner/repo(.git)?"), or null. Port is parsed but not kept: gb_deeplink's // own signature never takes one either (a deploy-key settings page is // always reached over a provider's standard port). function gbParseRemoteUrl(url) { var m; url = url || ''; m = url.match(/^https:\/\/([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (m) return { scheme: 'https', host: m[1], owner: m[2], repo: m[3] }; m = url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (m) return { scheme: 'ssh', host: m[1], owner: m[2], repo: m[3] }; // scp-like alias syntax: "[user@]host:owner/repo(.git)?" -- gb_parse_url // accepts this only when nothing before the first ":" contains a "/", // which is exactly what tells it apart from the two forms above. m = url.match(/^(?:[^@/:]+@)?([^/:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (m) return { scheme: 'ssh', host: m[1], owner: m[2], repo: m[3] }; return null; } // gbResolveProvider -- gb_provider's own logic: // an explicit, non-"auto" option wins outright; "auto" (the default) falls // back to a fixed host table, and anything else -- including a URL that // does not parse at all -- resolves to "generic". This is the function the // generic-remote acknowledgement gate and the deploy-key link/instructions // switch both key off of, and it is why neither can be a plain `.depends()` // on the "provider" option alone: "auto" against an unrecognized self-hosted // host is exactly the case gb_visibility_ok itself (package/gitbackup, // visibility.sh) treats as generic, and a UI that only reacted to an // explicit provider="generic" selection would silently skip the // acknowledgement gate for that router. function gbResolveProvider(url, providerOption) { var parsed; if (providerOption && providerOption !== 'auto') return providerOption; parsed = gbParseRemoteUrl(url); switch (parsed ? parsed.host : '') { case 'github.com': return 'github'; case 'gitlab.com': return 'gitlab'; case 'bitbucket.org': return 'bitbucket'; case 'codeberg.org': return 'gitea'; default: return 'generic'; } } // gbDeeplinkInfo -- one of: // { kind: 'unparsed' } -- does not match any of // the three accepted forms yet // { kind: 'link', url: '...' } -- a real "add deploy key" page // { kind: 'generic', host: '...' } -- no such page exists anywhere // Mirrors gb_deeplink's own five cases exactly (package/gitbackup, // remoteurl.sh) -- GitHub and Bitbucket hardcoded to their one cloud host // (gb_provider only ever resolves "github"/"bitbucket" for those two // hosts), GitLab/Gitea built from the remote's own host because both are // commonly self-hosted. gb_deeplink's own parameter only ever // matters to coerce a non-"http"/"https" scheme (i.e. "ssh") to "https" -- // gbParseRemoteUrl above never produces anything else, so that coercion is // already unconditionally true here and is not reproduced as a branch. function gbDeeplinkInfo(url, providerOption) { var parsed = gbParseRemoteUrl(url); var provider; if (!parsed) return { kind: 'unparsed' }; provider = gbResolveProvider(url, providerOption); switch (provider) { case 'github': return { kind: 'link', url: 'https://github.com/' + parsed.owner + '/' + parsed.repo + '/settings/keys/new' }; case 'gitlab': return { kind: 'link', url: 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo + '/-/settings/repository' }; case 'gitea': return { kind: 'link', url: 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo + '/settings/keys' }; case 'bitbucket': return { kind: 'link', url: 'https://bitbucket.org/' + parsed.owner + '/' + parsed.repo + '/admin/access-keys/' }; default: return { kind: 'generic', host: parsed.host }; } } // gbFieldValue
-- formvalue() when 's // own form widget already exists in the DOM, falling back to the SAVED // cfgvalue() otherwise. // // Ticket 25's second defect: form.js's own AbstractValue.formvalue // (modules/luci-base/htdocs/luci-static/resources/form.js) returns null // whenever getUIElement() finds nothing yet -- which is exactly the case // the very first checkDepends/renderWidget pass runs under, before this // section's own widgets have been created at all. Every checkDepends/ // renderWidget below that reads 'url' or 'provider' with a bare // `this.section.formvalue(section_id, ...)` therefore saw an empty url on // that very first paint, always resolved to the "generic" provider // (gbResolveProvider's own default case), and stayed on that first, // wrong answer until some LATER field edit made checkDepends run again -- // confirmed live on the owlab stand for the deploy-key link specifically: // 0 links on a freshly loaded Settings page, 1 the instant any field in // the "origin" section was merely touched (no value even had to change). // A saved config is exactly the case where cfgvalue() already has the // real answer sitting there before any widget exists, so falling back to // it -- rather than to a hardcoded default -- fixes the very first paint // without waiting for the framework's own later re-check. function gbFieldValue(section, section_id, name) { var v = section.formvalue(section_id, name); return (v != null) ? v : section.cfgvalue(section_id, name); } // --------------------------------------------------------------------- // Cron validator -- the JS half of the brief's "две реализации" acceptance // criterion. Kept in exact lock-step with schedule.sh's own gb_cron_valid/ // _gb_field_values (package/gitbackup, out of this ticket's zone, read // before writing this): same field bounds (minute 0-59, hour 0-23, // day-of-month 1-31, month 1-12, day-of-week 0-6 -- NOT 0-7, busybox has no // Sunday-as-7 alias), same "*", "*/N", "A", "A-B", "A-B/N", comma-list // grammar, same rejection of a leading/trailing/doubled comma, a reversed // range, an out-of-[min,max] bound or a step outside [1,max]. Verified // against the shared fixture file both sides run // (tests/fixtures/cron.tsv) by tests/settings_cron_fixture.test.js (this // ticket's own zone) -- a mismatch there is exactly the "расхождение // обязано красить CI" the brief calls for, even though wiring that check // into the project's actual CI gate is ticket 15's job, not this one's. // // A pure decimal parseInt is used throughout, unlike schedule.sh's own // _gb_strip_lead0 dance: that workaround exists only because busybox ash's // `$(( ))` reads a leading zero as an octal prefix (verified on the stand, // interfaces.md ticket 06) -- `parseInt('08', 10)` has no such ambiguity. function gbFieldValid(expr, min, max) { var terms, i, term, rangeStr, stepStr, step, idx, loStr, hiStr, lo, hi; // A leading, trailing or doubled comma has to be rejected before the // split below: naive splitting on "," would otherwise turn "5," into // the single term "5" (JS, unlike busybox ash's IFS-splitting, does // keep a trailing empty field, but that empty field is caught the same // way any other empty term is below -- checked here up front anyway so // this stays one rule instead of two, same as _gb_field_values' own // comment explains for its own, differently-shaped pitfall). if (expr === '' || /^,|,$|,,/.test(expr)) return false; terms = expr.split(','); for (i = 0; i < terms.length; i++) { term = terms[i]; if (!term) return false; rangeStr = term; idx = term.indexOf('/'); if (idx !== -1) { rangeStr = term.slice(0, idx); stepStr = term.slice(idx + 1); if (!/^[0-9]+$/.test(stepStr)) return false; step = parseInt(stepStr, 10); // A step of 0, or bigger than the field's own span, parses // without complaint in real busybox crond but silently // degenerates to firing on the field's single lowest value // forever (measured on the stand, interfaces.md ticket 06) -- // exactly the "looks configured, never really runs" trap this // validator exists to catch before it reaches a crontab. if (step < 1 || step > max) return false; } if (rangeStr === '*') { // min/max are trivially satisfied; nothing to validate. } else if (rangeStr.indexOf('-') !== -1) { idx = rangeStr.indexOf('-'); loStr = rangeStr.slice(0, idx); hiStr = rangeStr.slice(idx + 1); if (!/^[0-9]+$/.test(loStr) || !/^[0-9]+$/.test(hiStr)) return false; lo = parseInt(loStr, 10); hi = parseInt(hiStr, 10); if (lo < min || lo > max || hi < min || hi > max) return false; // A reversed range ("5-2") parses in real busybox crond too, // but produces a bitmap wrong in a way no user intended // (measured on the stand) -- rejected here instead of // reproduced. if (lo > hi) return false; } else { if (!/^[0-9]+$/.test(rangeStr)) return false; lo = parseInt(rangeStr, 10); if (lo < min || lo > max) return false; } } return true; } // gbCronValid -- { valid: true } or { valid: false, reason: '...' }, // reason text copied verbatim from gb_cron_valid's own printf strings // (schedule.sh) so the two validators do not merely agree on the verdict // but say the same thing for it -- the brief's own acceptance criterion // ("@daily отклоняется обоими с одинаковым текстом") asks for exactly // that, not just a matching boolean. function gbCronValid(expr) { var e = expr || ''; var trimmed, fields; if (e.charAt(0) === '@') { return { valid: false, reason: _('busybox crond does not understand "%s" (no @-macro support on 25.12); use an explicit expression, e.g. "0 3 * * *" for once a day at 03:00').format(e) }; } trimmed = e.replace(/^\s+|\s+$/g, ''); fields = trimmed ? trimmed.split(/\s+/) : []; if (fields.length !== 5) { return { valid: false, reason: _('a cron expression needs exactly 5 fields (minute hour day-of-month month day-of-week), got %d in "%s"').format(fields.length, e) }; } if (!gbFieldValid(fields[0], 0, 59)) return { valid: false, reason: _('%s field is not a valid value, range, step or list of them in "%s"').format('minute', e) }; if (!gbFieldValid(fields[1], 0, 23)) return { valid: false, reason: _('%s field is not a valid value, range, step or list of them in "%s"').format('hour', e) }; if (!gbFieldValid(fields[2], 1, 31)) return { valid: false, reason: _('%s field is not a valid value, range, step or list of them in "%s"').format('day-of-month', e) }; if (!gbFieldValid(fields[3], 1, 12)) return { valid: false, reason: _('%s field is not a valid value, range, step or list of them in "%s"').format('month', e) }; if (!gbFieldValid(fields[4], 0, 6)) return { valid: false, reason: _('%s field is not a valid value, range, step or list of them in "%s"').format('day-of-week', e) }; return { valid: true }; } // --------------------------------------------------------------------- // Copy-without-secure-context (spec, verbatim): navigator.clipboard is // only reachable in a secure context, and LuCI reached over plain LAN // http:// -- overwhelmingly the common case for a home router -- is not // one. The fallback builds a throwaway