e8d1fff44e0f7ecd4629cfcc2ac671d6af78d8ae hiram Thu Apr 30 12:54:54 2026 -0700 add an ottoRequest table interactive CGI viewer and verify lift.over URL links for email are valid refs #31811 diff --git src/hg/utils/otto/userRequests/ottoRequestView.cgi src/hg/utils/otto/userRequests/ottoRequestView.cgi new file mode 100644 index 00000000000..c0338e31cd9 --- /dev/null +++ src/hg/utils/otto/userRequests/ottoRequestView.cgi @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""ottoRequestView.cgi - web view of hgcentraltest.ottoRequest. + +Read-only display of every row in the table, plus a per-row 'reset +status' control that is the only write path exposed. + +Access is restricted to a single IP (UCSC VPN, 128.114.198.5). +Any other REMOTE_ADDR gets a 403. +""" + +import cgi +import html +import os +import subprocess +import sys + +ALLOWED_IP = '128.114.198.5' +HGDB_CONF = '/usr/local/apache/cgi-bin/hg.conf' +TRASH = '/data/apache/trash' +DB = 'hgcentraltest' +TABLE = 'ottoRequest' + +# from README.txt in this directory +STATUS_NAMES = { + 0: 'received by API', + 1: 'acknowledged, email sent', + 2: 'galaxy job started', + 3: 'galaxy done, download started', + 4: 'downloaded, track files made', + 5: 'symlinks ready, awaiting push', + 6: 'push complete', + 7: 'ERROR', + 8: 'COMPLETE (final email sent)', +} + +COLS = ['id', 'requestType', 'fromDb', 'toDb', 'email', 'comment', + 'requestTime', 'status', 'buildDir', 'completeTime'] + + +def forbidden(msg): + sys.stdout.write("Status: 403 Forbidden\r\n") + sys.stdout.write("Content-Type: text/plain; charset=utf-8\r\n\r\n") + sys.stdout.write(msg + "\n") + sys.exit(0) + + +def checkIp(): + remote = os.environ.get('REMOTE_ADDR', '') + if remote != ALLOWED_IP: + forbidden(f"Access denied for {remote!r}; this page is restricted " + f"to {ALLOWED_IP}.") + + +def unescapeMysql(s): + """Reverse `hgsql -B` escaping (\\n, \\t, \\\\, \\0). One pass so + \\\\n stays a literal backslash + 'n'.""" + out, i, n = [], 0, len(s) + while i < n: + if s[i] == '\\' and i + 1 < n: + c = s[i+1] + if c == 'n': out.append('\n') + elif c == 't': out.append('\t') + elif c == '\\': out.append('\\') + elif c == '0': out.append('\0') + else: out.append(s[i:i+2]) + i += 2 + else: + out.append(s[i]); i += 1 + return ''.join(out) + + +def hgsqlRun(sql): + """Run sql via hgsql against DB. Returns (ok, stdout, stderr). + Running under Apache the process has no ~/.hg.conf, so point hgsql + at the cgi-bin hg.conf via HGDB_CONF.""" + env = dict(os.environ) + env['HGDB_CONF'] = HGDB_CONF + env['HOME'] = TRASH + cmd = ['/cluster/bin/x86_64/hgsql', DB, '-N', '-B', '-e', sql] + r = subprocess.run(cmd, capture_output=True, text=True, env=env) + return (r.returncode == 0, r.stdout, r.stderr) + + +def fetchRows(): + sql = f"SELECT {','.join(COLS)} FROM {TABLE} ORDER BY id DESC" + ok, out, err = hgsqlRun(sql) + if not ok: + raise RuntimeError(err.strip() or 'hgsql failed') + rows = [] + if out.strip(): + for line in out.rstrip('\n').split('\n'): + rows.append([unescapeMysql(f) for f in line.split('\t')]) + return rows + + +def doResetStatus(form): + rid = form.getfirst('id', '') + stat = form.getfirst('status', '') + if not rid.isdigit(): + return None, f"bad id: {rid!r}" + if not stat.isdigit() or int(stat) not in STATUS_NAMES: + return None, f"bad status: {stat!r}" + sql = (f"UPDATE {TABLE} SET status = {int(stat)} " + f"WHERE id = {int(rid)}") + ok, _out, err = hgsqlRun(sql) + if not ok: + return None, err.strip() or 'hgsql update failed' + return (f"id={rid} status set to {stat} " + f"({STATUS_NAMES[int(stat)]})"), None + + +def renderPage(rows, info=None, error=None): + sys.stdout.write("Content-Type: text/html; charset=utf-8\r\n\r\n") + out = sys.stdout.write + + out('\n\n') + out(f'{TABLE}\n') + out('\n') + + out(f'

{DB}.{TABLE}

\n') + if info: + out(f'\n') + if error: + out(f'\n') + + out('
status: ') + out(' · '.join(f'{k}={html.escape(v)}' + for k, v in STATUS_NAMES.items())) + out(f' · {len(rows)} row(s) · ' + f'refresh
\n') + + out('\n') + for c in COLS: + out(f'') + out('\n') + + for r in rows: + rid = r[0] + try: + stnum = int(r[7]) + except (ValueError, IndexError): + stnum = -1 + cls = f's{stnum}' if stnum in (7, 8) else '' + out(f'') + for i, c in enumerate(COLS): + cell = r[i] if i < len(r) else '' + if c == 'comment': + out(f'') + elif c == 'status': + label = STATUS_NAMES.get(stnum, '?') + out(f'') + else: + out(f'') + # reset form + out('') + out('\n') + out('
{c}set status
{html.escape(cell)}{html.escape(cell)} ' + f'{html.escape(label)}{html.escape(cell)}
' + '' + f'' + '
\n\n') + + +def main(): + checkIp() + + info = error = None + if os.environ.get('REQUEST_METHOD', 'GET') == 'POST': + form = cgi.FieldStorage() + action = form.getfirst('action', '') + if action == 'resetStatus': + info, error = doResetStatus(form) + else: + error = f"unknown action: {action!r}" + + try: + rows = fetchRows() + except RuntimeError as e: + rows = [] + error = (error + ' / ' if error else '') + f"fetch failed: {e}" + + renderPage(rows, info=info, error=error) + + +if __name__ == '__main__': + try: + main() + except Exception as e: + sys.stdout.write("Content-Type: text/plain; charset=utf-8\r\n\r\n") + sys.stdout.write(f"ottoRequestView.cgi error: {e}\n")