#!/usr/bin/env python3 """ Patches the TrueNAS webui Angular bundle to show S3 and B2 credentials in the TrueCloud Backup task form, instead of Storj only. The template binding [filterByProviders]="[CloudSyncProviderName.Storj]" appears in the minified JS in one of two forms depending on TrueNAS / Angular version: TrueNAS 24.x (static inline array): "filterByProviders",["STORJ_IX"] TrueNAS 25.x+ (Angular pureFunction binding, inside a chained property call): c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))("required",!0) Both are replaced so the dropdown includes S3 and B2. The file is backed up before modification so uninstall.sh can restore it. Safe to run multiple times — a marker string detects an already-patched file. Exits 0 in all cases (warnings are printed to stdout and logged by apply.sh). """ import contextlib import os import re import shutil WEBUI_CANDIDATES = [ "/usr/share/truenas/webui", "/usr/share/truenas-ui", "/var/www/truenas", ] # Patterns tried in order; the first match wins. # Each entry is (compiled_regex, replacement_string). _PATTERNS = [ # TrueNAS 25.x+: Angular emits a pureFunction call instead of a literal array, # inside a CHAINED property binding — so the call is followed by two closing # parens, one for pe(...) and one for the property(...) it sits in: # # c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))("required",!0) # ^^ # The pattern consumes both and re-emits one, leaving the paren balance # unchanged. Getting that wrong is a syntax error in the bundle and the whole # web UI goes blank — see tests/test_patch_ui.py. # # The minified names (pe / slot index / Rn / i) change across builds; # CloudSyncProviderName.Storj is stable because it is a TypeScript enum name. (re.compile(r'("filterByProviders",)\w+\(\d+,\w+,\w+\.CloudSyncProviderName\.Storj\)\)'), r'\1["STORJ_IX","S3","B2"])'), # TrueNAS 24.x and earlier: static inline array. (re.compile(r'("filterByProviders",)\["STORJ_IX"\]'), r'\1["STORJ_IX","S3","B2"]'), ] # Present in any patched file. Specific enough not to appear elsewhere. MARKER = '"STORJ_IX","S3","B2"' def _match_pattern(content): """Return (regex, replacement) for the first pattern found in content, or (None, None).""" for find, replace in _PATTERNS: if find.search(content): return find, replace return None, None def _paren_delta(s): """Net parenthesis balance. Patching must not change it — see main().""" return s.count("(") - s.count(")") def find_bundle(): """ Search WEBUI_CANDIDATES for the JS chunk containing the filterByProviders binding. Returns (webui_dir, path, content). webui_dir is None if no candidate directory exists; path is None if the directory exists but the pattern is not found in any file. """ webui = next((d for d in WEBUI_CANDIDATES if os.path.isdir(d)), None) if webui is None: return None, None, None for root, _dirs, names in os.walk(webui): for name in sorted(names): # deterministic order if not name.endswith(".js"): continue path = os.path.join(root, name) try: with open(path, encoding="utf-8", errors="replace") as fh: content = fh.read() find, _ = _match_pattern(content) if find is not None or MARKER in content: return webui, path, content except OSError: continue return webui, None, None def main(): webui, path, content = find_bundle() if webui is None: print( "[truecloud-patch] WARNING: webui directory not found; skipping UI patch.\n" "[truecloud-patch] Searched: " + ", ".join(WEBUI_CANDIDATES) ) return if path is None: print( "[truecloud-patch] WARNING: filterByProviders pattern not found in any JS bundle.\n" "[truecloud-patch] The TrueNAS webui may have been restructured in this version.\n" "[truecloud-patch] File an issue at https://github.com/sudolulo/truenas-truecloud-patch\n" f"[truecloud-patch] TrueNAS version info: {_tnversion()}" ) return if MARKER in content: print(f"[truecloud-patch] UI already patched: {path}") return find, replace = _match_pattern(content) backup = path + ".pre-truecloud-patch" if not os.path.exists(backup): try: shutil.copy2(path, backup) except OSError as exc: print(f"[truecloud-patch] ERROR: Could not create backup {backup}: {exc}") return patched, count = find.subn(replace, content) if count != 1: print( f"[truecloud-patch] WARNING: {count} replacement(s) in {path}; " f"expected exactly 1 — skipping write to avoid corrupting the bundle.\n" f"[truecloud-patch] File an issue at " f"https://github.com/sudolulo/truenas-truecloud-patch" ) return # Never write JS whose parentheses we have unbalanced. A pattern that eats one # paren too many is a syntax error in the bundle and the entire TrueNAS web UI # goes blank -- and because MARKER is then present, every later run reports # "already patched" and skips, so the patch cannot heal itself. Recovery means # hand-restoring the .pre-truecloud-patch backup. # # This is not hypothetical: it shipped once. Refuse instead. if _paren_delta(patched) != _paren_delta(content): print( "[truecloud-patch] ERROR: the replacement would unbalance the bundle's " "parentheses — refusing to write.\n" "[truecloud-patch] The UI is UNCHANGED and still works. This means the " "pattern no longer fits this TrueNAS build.\n" "[truecloud-patch] File an issue at " "https://github.com/sudolulo/truenas-truecloud-patch" ) return tmp = path + ".tmp" try: with open(tmp, "w", encoding="utf-8") as fh: fh.write(patched) os.replace(tmp, path) except OSError as exc: print(f"[truecloud-patch] ERROR: Could not write {path}: {exc}") with contextlib.suppress(OSError): os.unlink(tmp) return print(f"[truecloud-patch] UI bundle patched ({count} replacement(s)): {path}") def _tnversion(): try: with open("/etc/version") as fh: return fh.read().strip() except OSError: return "unknown" if __name__ == "__main__": main()