"),gr=p(""),hr=p(" ");function se(e){return(()=>{var t=fr();return u(t,()=>e.children),L(()=>X(t,`overflow-auto rounded-md border border-border ${e.class||""}`)),t})()}function ie(e){return(()=>{var t=pr();return u(t,()=>e.children),L(()=>X(t,`min-w-full divide-y divide-border text-sm ${e.class||""}`)),t})()}function C(e){return(()=>{var t=gr();return u(t,()=>e.children),L(()=>X(t,`bg-secondary/60 px-3 py-2 text-left text-xs uppercase tracking-wide text-muted-foreground ${e.class||""}`)),t})()}function P(e){return(()=>{var t=hr();return u(t,()=>e.children),L(()=>X(t,`px-3 py-2 align-top ${e.class||""}`)),t})()}var mr=p('Add Service'),br=p('Protected Services'),yr=p(""),$r=p(" "),vr=p(''),xr=p("
"),wr=p('
'),_r=p(""),Sr=p('');function Cr(){const[e,{refetch:t}]=F(Rt),[r,n]=y(""),[s,i]=y(""),[o,a]=y(""),[h,m]=y(!1);async function d(){if(!(!r().trim()||!s().trim()||!o().trim())){m(!0);try{await Dt({name:r().trim(),upstreamUrl:s().trim(),routePrefix:o().trim()}),n(""),i(""),a(""),await t()}finally{m(!1)}}}return(()=>{var l=vr();return u(l,c(A,{class:"space-y-3",get children(){return[mr(),c(D,{placeholder:"Billing API",get value(){return r()},onInput:f=>n(f.currentTarget.value)}),c(D,{placeholder:"http://localhost:8080",get value(){return s()},onInput:f=>i(f.currentTarget.value)}),c(D,{placeholder:"/v1/billing",get value(){return o()},onInput:f=>a(f.currentTarget.value)}),c(E,{onClick:()=>{d()},get disabled(){return h()},get children(){return h()?"Creating...":"Create Service"}})]}}),null),u(l,c(A,{get children(){return[br(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var f=yr(),g=f.firstChild;return u(g,c(C,{children:"Name"}),null),u(g,c(C,{children:"Route"}),null),u(g,c(C,{children:"Status"}),null),u(g,c(C,{children:"Actions"}),null),f})(),(()=>{var f=$r();return u(f,c(K,{get each(){return e()||[]},children:g=>(()=>{var b=Sr();return u(b,c(P,{get children(){return[(()=>{var x=xr();return u(x,()=>g.name),x})(),(()=>{var x=wr();return u(x,()=>g.upstreamUrl),x})()]}}),null),u(b,c(P,{get children(){var x=_r();return u(x,()=>g.routePrefix),x}}),null),u(b,c(P,{get children(){return c(le,{get label(){return g.lastValidationStatus||"unknown"},get tone(){return Q(()=>g.lastValidationStatus==="healthy")()?"success":g.lastValidationStatus==="failed"?"danger":"default"}})}}),null),u(b,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"outline",onClick:()=>{Mt(g.id).then(()=>t())},children:"Validate"}),c(E,{variant:"secondary",onClick:()=>{Ut(g.id,{enabled:!g.enabled}).then(()=>t())},get children(){return g.enabled?"Disable":"Enable"}})]}}),null),b})()})),f})()]}})}})]}}),null),l})()}var Pr=p("");function Te(e){return(()=>{var t=Pr();return $e(t,be(e,{get class(){return`w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground outline-none focus:border-primary ${e.class||""}`}}),!1,!0),u(t,()=>e.children),t})()}var Ar=p('Add Database Connector'),kr=p("SQLite"),Or=p(" Postgres"),Tr=p(" MySQL"),Er=p('Database Connectors'),Ir=p(""),Nr=p(" "),Lr=p(''),Rr=p("
"),Dr=p('
'),Ur=p(""),Mr=p('');function jr(){const[e,{refetch:t}]=F(jt),[r,n]=y(""),[s,i]=y("sqlite"),[o,a]=y("");async function h(){!r().trim()||!o().trim()||(await Vt({name:r().trim(),provider:s(),connectionUrl:o().trim()}),n(""),a(""),await t())}return(()=>{var m=Lr();return u(m,c(A,{class:"space-y-3",get children(){return[Ar(),c(D,{placeholder:"Primary Database",get value(){return r()},onInput:d=>n(d.currentTarget.value)}),c(Te,{get value(){return s()},onChange:d=>i(d.currentTarget.value),get children(){return[kr(),Or(),Tr()]}}),c(D,{get placeholder(){return Q(()=>s()==="sqlite")()?"./data/app.sqlite":s()==="postgres"?"postgres://user:pass@host:5432/db":"mysql://user:pass@host:3306/db"},get value(){return o()},onInput:d=>a(d.currentTarget.value)}),c(E,{onClick:()=>{h()},children:"Create Connector"})]}}),null),u(m,c(A,{get children(){return[Er(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=Ir(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Provider"}),null),u(l,c(C,{children:"Target"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Actions"}),null),d})(),(()=>{var d=Nr();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Mr();return u(f,c(P,{get children(){return[(()=>{var g=Rr();return u(g,()=>l.name),g})(),(()=>{var g=Dr();return u(g,()=>l.maskedConnectionUrl),g})()]}}),null),u(f,c(P,{get children(){return l.provider}}),null),u(f,c(P,{get children(){var g=Ur();return u(g,()=>l.target),g}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.lastValidationStatus||"unknown"},get tone(){return Q(()=>l.lastValidationStatus==="healthy")()?"success":l.lastValidationStatus==="failed"?"danger":"default"}})}}),null),u(f,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"outline",onClick:()=>{qt(l.id).then(()=>t())},children:"Validate"}),c(E,{variant:"secondary",onClick:()=>{Bt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})]}}),null),f})()})),d})()]}})}})]}}),null),m})()}var Vr=p('Create API Key'),Br=p("Free"),qr=p(" Pro"),Fr=p(" Business"),Kr=p(" Enterprise"),Jr=p('API Keys'),Hr=p(""),Gr=p(" "),Wr=p(''),Qr=p('
Generated once
'),Xr=p("
"),Yr=p('
Last used: '),zr=p(""),Zr=p('');function en(){const[e,{refetch:t}]=F(Ft),[r,n]=y(""),[s,i]=y("pro"),[o,a]=y(null);async function h(){if(!r().trim())return;const m=await Kt({name:r().trim(),plan:s()});a(m.data.key),n(""),await t()}return(()=>{var m=Wr();return u(m,c(A,{class:"space-y-3",get children(){return[Vr(),c(D,{placeholder:"production-client",get value(){return r()},onInput:d=>n(d.currentTarget.value)}),c(Te,{get value(){return s()},onChange:d=>i(d.currentTarget.value),get children(){return[Br(),qr(),Fr(),Kr()]}}),c(E,{onClick:()=>{h()},children:"Create Key"}),c(ye,{get when(){return o()},children:d=>(()=>{var l=Qr(),f=l.firstChild,g=f.nextSibling;return u(g,d),l})()})]}}),null),u(m,c(A,{get children(){return[Jr(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=Hr(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Plan"}),null),u(l,c(C,{children:"Prefix"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Action"}),null),d})(),(()=>{var d=Gr();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Zr();return u(f,c(P,{get children(){return[(()=>{var g=Xr();return u(g,()=>l.name),g})(),(()=>{var g=Yr();return g.firstChild,u(g,()=>l.lastUsedAt||"never",null),g})()]}}),null),u(f,c(P,{get children(){return l.plan}}),null),u(f,c(P,{get children(){var g=zr();return u(g,()=>l.keyPrefix),g}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.enabled?"enabled":"disabled"},get tone(){return l.enabled?"success":"danger"}})}}),null),u(f,c(P,{get children(){return c(E,{variant:"secondary",onClick:()=>{Jt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})}}),null),f})()})),d})()]}})}})]}}),null),m})()}var tn=p(""),rn=p('
'),nn=p('
Users'),oe=p('
'),ln=p('
Services'),sn=p('
DB Connectors'),an=p('
Monthly Requests'),on=p('
Hourly Request Trend (24h)'),cn=p('Incidents By Severity'),un=p('Traffic (Umami + Client Events)'),dn=p(''),fn=p('Top Client Event Paths'),pn=p('
Ops + Traffic Analytics Refresh
'),gn=p('
Umami API not configured or unavailable. Showing local client events.');function _e(e){const t=Math.max(1,...e.values.map(r=>r.value));return(()=>{var r=tn();return u(r,c(K,{get each(){return e.values},children:n=>(()=>{var s=rn(),i=s.firstChild,o=i.firstChild,a=o.nextSibling,h=i.nextSibling,m=h.firstChild;return u(o,()=>n.label),u(a,()=>n.value),L(d=>Ct(m,"width",`${n.value/t*100}%`)),s})()})),r})()}function hn(){const[e,{refetch:t}]=F(()=>Yt()),[r,{refetch:n}]=F(()=>zt()),s=async()=>{await t(),await n()};return(()=>{var i=pn(),o=i.firstChild,a=o.firstChild,h=a.nextSibling,m=o.nextSibling,d=m.nextSibling;return h.$$click=()=>{s()},u(m,c(A,{get children(){return[nn(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.users??0),l})()]}}),null),u(m,c(A,{get children(){return[ln(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.services??0),l})()]}}),null),u(m,c(A,{get children(){return[sn(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.databases??0),l})()]}}),null),u(m,c(A,{get children(){return[an(),(()=>{var l=oe();return u(l,()=>e()?.data?.requests?.total??0),l})()]}}),null),u(d,c(A,{class:"space-y-3",get children(){return[on(),c(_e,{get values(){return(e()?.data?.requests?.hourly||[]).map(l=>({label:String(l.bucket).slice(11,16),value:Number(l.value||0)}))}})]}}),null),u(d,c(A,{class:"space-y-3",get children(){return[cn(),c(_e,{get values(){return Object.entries(e()?.data?.incidentsBySeverity||{}).map(([l,f])=>({label:l,value:Number(f)}))}})]}}),null),u(i,c(A,{class:"space-y-3",get children(){return[un(),c(ye,{get when(){return r()?.data?.umami?.enabled},get fallback(){return gn()},get children(){var l=dn();return u(l,()=>JSON.stringify(r()?.data?.umami||{},null,2)),l}}),(()=>{var l=fn();return l.firstChild,u(l,c(_e,{get values(){return(r()?.data?.clientEvents||[]).map(f=>({label:String(f.path),value:Number(f.count||0)}))}}),null),l})()]}}),null),i})()}Oe(["click"]);var mn=p('
Create User Invite'),bn=p("Viewer (default)"),yn=p('Users'),$n=p(""),vn=p(" "),xn=p(''),wn=p("
"),_n=p('Invite created
Invite token:
Temporary password: '),Sn=p(""),Cn=p('
'),Pn=p('
');function An(){const[e,{refetch:t}]=F(Ht),[r]=F(He),[n,s]=y(""),[i,o]=y(""),[a,h]=y(null);async function m(){if(!n().trim())return;const d=await Gt({email:n().trim(),roleIds:i()?[i()]:[]});h(d),s(""),await t()}return(()=>{var d=xn();return u(d,c(A,{class:"space-y-3",get children(){return[mn(),c(D,{placeholder:"teammate@example.com",get value(){return n()},onInput:l=>s(l.currentTarget.value)}),c(Te,{get value(){return i()},onChange:l=>o(l.currentTarget.value),get children(){return[bn(),c(K,{get each(){return r()||[]},children:l=>(()=>{var f=wn();return u(f,()=>l.name),L(()=>f.value=l.id),f})()})]}}),c(E,{onClick:()=>{m()},children:"Create User"}),c(ye,{get when(){return a()},children:l=>(()=>{var f=_n(),g=f.firstChild,b=g.nextSibling,x=b.firstChild,V=x.nextSibling,U=b.nextSibling,T=U.firstChild,O=T.nextSibling;return u(V,()=>l().data?.inviteToken),u(O,()=>l().data?.temporaryPass),f})()})]}}),null),u(d,c(A,{get children(){return[yn(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var l=$n(),f=l.firstChild;return u(f,c(C,{children:"Email"}),null),u(f,c(C,{children:"Roles"}),null),u(f,c(C,{children:"Status"}),null),u(f,c(C,{children:"Actions"}),null),l})(),(()=>{var l=vn();return u(l,c(K,{get each(){return e()||[]},children:f=>(()=>{var g=Pn();return u(g,c(P,{get children(){return[(()=>{var b=Sn();return u(b,()=>f.email),b})(),(()=>{var b=Cn();return u(b,()=>f.id),b})()]}}),null),u(g,c(P,{get children(){return f.roles.join(", ")||"-"}}),null),u(g,c(P,{get children(){return c(le,{get label(){return f.enabled?"enabled":"disabled"},get tone(){return f.enabled?"success":"danger"}})}}),null),u(g,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"secondary",onClick:()=>{Me(f.id,{enabled:!f.enabled}).then(()=>t())},get children(){return f.enabled?"Disable":"Enable"}}),c(E,{variant:"outline",onClick:()=>{Me(f.id,{resetPassword:!0}).then(()=>t())},children:"Reset Pass"})]}}),null),g})()})),l})()]}})}})]}}),null),d})()}var kn=p('Create Role'),On=p(''),Tn=p('
Roles'),En=p(""),In=p(" "),Nn=p(''),Ln=p('
'),Rn=p(""),Dn=p('
'),Un=p(""),Mn=p(''),jn=p('system');function Vn(){const[e,{refetch:t}]=F(He),[r]=F(Xt),[n,s]=y(""),[i,o]=y([]);function a(m){o(d=>d.includes(m)?d.filter(l=>l!==m):[...d,m])}async function h(){n().trim()&&(await Wt({name:n().trim(),permissionCodes:i()}),s(""),o([]),await t())}return(()=>{var m=Nn();return u(m,c(A,{class:"space-y-3",get children(){return[kn(),c(D,{placeholder:"Ops Manager",get value(){return n()},onInput:d=>s(d.currentTarget.value)}),(()=>{var d=On();return u(d,c(K,{get each(){return r()||[]},children:l=>(()=>{var f=Ln(),g=f.firstChild,b=g.nextSibling;return g.addEventListener("change",()=>a(l.code)),u(b,()=>l.code),L(()=>g.checked=i().includes(l.code)),f})()})),d})(),c(E,{onClick:()=>{h()},children:"Create Role"})]}}),null),u(m,c(A,{get children(){return[Tn(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=En(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Slug"}),null),u(l,c(C,{children:"Permissions"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Action"}),null),d})(),(()=>{var d=In();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Mn();return u(f,c(P,{get children(){return[(()=>{var g=Rn();return u(g,()=>l.name),g})(),(()=>{var g=Dn();return u(g,()=>l.description),g})()]}}),null),u(f,c(P,{get children(){return[(()=>{var g=Un();return u(g,()=>l.slug),g})(),Q(()=>Q(()=>!!l.isSystem)()&&jn())]}}),null),u(f,c(P,{class:"max-w-[320px] text-xs text-muted-foreground",get children(){return l.permissionCodes.join(", ")||"-"}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.enabled?"enabled":"disabled"},get tone(){return l.enabled?"success":"danger"}})}}),null),u(f,c(P,{get children(){return c(E,{variant:"secondary",onClick:()=>{Qt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})}}),null),f})()})),d})()]}})}})]}}),null),m})()}var Bn=p('Runtime'),qn=p(' APwhy uses a Go backend with SQLite control-plane storage and modular SolidJS frontend.'),Fn=p('
Use Docker + Traefik + Umami stack from `deploy/docker-compose.yml` for production self-hosting.'),Kn=p('
Security'),Jn=p(' Owner bootstrap is one-time. After first user exists, registration is closed and login-only flow is enforced.'),Hn=p('
Role permissions control access to services, keys, users, roles, and analytics APIs.'),Gn=p('
');function Wn(){return(()=>{var e=Gn();return u(e,c(A,{class:"space-y-2",get children(){return[Bn(),qn(),Fn()]}}),null),u(e,c(A,{class:"space-y-2",get children(){return[Kn(),Jn(),Hn()]}}),null),e})()}var Qn=p('
Loading APwhy...'),Xn=p('
Password reset required'),Yn=p(' Your account must set a new password before using all dashboard actions.'),zn=p('
'),Zn=p('
APwhy
API Routing + Management Hub Green-focused, self-hosted control plane with auth bootstrap, RBAC, gateway, and analytics.
'),el=p("");function tl(){const[e,t]=y(!0),[r,n]=y(!1),[s,i]=y(null),[o,a]=y("analytics"),[h,m]=y(""),d=[{id:"analytics",label:"Analytics"},{id:"services",label:"Services"},{id:"databases",label:"Databases"},{id:"keys",label:"API Keys"},{id:"users",label:"Users"},{id:"roles",label:"Roles"},{id:"settings",label:"Settings"}];async function l(){t(!0);try{const b=await Tt();if(n(b.registrationOpen),!b.registrationOpen)try{const x=await Ue();i(x)}catch{i(null)}}finally{t(!1)}}Ee(()=>{l()}),Ee(()=>{s()&&Zt("dashboard_tab",o(),{tab:o()}).catch(()=>{})});async function f(){await Nt(),i(null)}async function g(){if(!h().trim())return;await Lt(h().trim());const b=await Ue();i(b),m("")}return e()?Qn():r()?c(ur,{onComplete:l}):s()?(()=>{var b=Zn(),x=b.firstChild,V=x.firstChild,U=V.firstChild,T=U.firstChild,O=T.nextSibling,k=O.firstChild,M=k.firstChild,G=M.nextSibling,Z=U.nextSibling,ee=V.nextSibling;return u(M,()=>s()?.email),u(G,()=>s()?.roles.join(", ")),u(O,c(E,{variant:"outline",onClick:()=>{f()},children:"Sign Out"}),null),u(Z,c(K,{each:d,children:w=>(()=>{var $=el();return $.$$click=()=>a(w.id),u($,()=>w.label),L(()=>X($,`rounded-md border px-3 py-1.5 text-xs font-semibold ${o()===w.id?"border-primary bg-primary/20 text-primary":"border-border bg-secondary/40 text-foreground"}`)),$})()})),u(x,c(ye,{get when(){return s()?.forcePasswordReset},get children(){return c(A,{class:"mt-4 space-y-3 border-amber-500/45 bg-amber-500/10",get children(){return[Xn(),Yn(),(()=>{var w=zn();return u(w,c(D,{type:"password",placeholder:"New password",get value(){return h()},onInput:$=>m($.currentTarget.value)}),null),u(w,c(E,{onClick:()=>{g()},children:"Save"}),null),w})()]}})}}),ee),u(ee,c(dt,{get children(){return[c(W,{get when(){return o()==="analytics"},get children(){return c(hn,{})}}),c(W,{get when(){return o()==="services"},get children(){return c(Cr,{})}}),c(W,{get when(){return o()==="databases"},get children(){return c(jr,{})}}),c(W,{get when(){return o()==="keys"},get children(){return c(en,{})}}),c(W,{get when(){return o()==="users"},get children(){return c(An,{})}}),c(W,{get when(){return o()==="roles"},get children(){return c(Vn,{})}}),c(W,{get when(){return o()==="settings"},get children(){return c(Wn,{})}})]}})),b})():c(ir,{onLoggedIn:async()=>l()})}Oe(["click"]);vt(()=>c(tl,{}),document.getElementById("root"));
diff --git a/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 b/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2
deleted file mode 100644
index 642e90f..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff b/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff
deleted file mode 100644
index 10cee4d..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 b/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2
deleted file mode 100644
index 6b4ac49..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff b/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff
deleted file mode 100644
index 10ebb9f..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff b/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff
deleted file mode 100644
index 8f29ea1..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 b/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2
deleted file mode 100644
index 3156c54..0000000
Binary files a/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 b/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2
deleted file mode 100644
index c7f3d55..0000000
Binary files a/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff b/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff
deleted file mode 100644
index 517d2b3..0000000
Binary files a/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 b/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2
deleted file mode 100644
index 4e80f6b..0000000
Binary files a/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff b/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff
deleted file mode 100644
index 022a8bf..0000000
Binary files a/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 b/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2
deleted file mode 100644
index 8f704ea..0000000
Binary files a/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff b/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff
deleted file mode 100644
index c220933..0000000
Binary files a/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff b/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff
deleted file mode 100644
index f312fbb..0000000
Binary files a/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 b/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2
deleted file mode 100644
index 7eaaa95..0000000
Binary files a/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 b/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2
deleted file mode 100644
index 7cc09a3..0000000
Binary files a/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff b/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff
deleted file mode 100644
index 3fe76a6..0000000
Binary files a/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 b/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2
deleted file mode 100644
index 84f7af5..0000000
Binary files a/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff b/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff
deleted file mode 100644
index 6000ec7..0000000
Binary files a/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff b/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff
deleted file mode 100644
index add2e3c..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 b/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2
deleted file mode 100644
index a56d446..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 b/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2
deleted file mode 100644
index 9ceb22a..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff b/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff
deleted file mode 100644
index 4ba5d43..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 b/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2
deleted file mode 100644
index 5e6b5ae..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff b/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff
deleted file mode 100644
index 655902d..0000000
Binary files a/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff b/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff
deleted file mode 100644
index 7f9ff87..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 b/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2
deleted file mode 100644
index 3b2938e..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 b/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2
deleted file mode 100644
index 8714f1d..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff b/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff
deleted file mode 100644
index 84df44d..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 b/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2
deleted file mode 100644
index 0d36914..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 and /dev/null differ
diff --git a/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff b/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff
deleted file mode 100644
index e43daa0..0000000
Binary files a/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff and /dev/null differ
diff --git a/internal/api/static/index.html b/internal/api/static/index.html
deleted file mode 100644
index 360fca5..0000000
--- a/internal/api/static/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
- APwhy
-
-
-
-
-
-
-
diff --git a/internal/api/templates.go b/internal/api/templates.go
deleted file mode 100644
index 475e7be..0000000
--- a/internal/api/templates.go
+++ /dev/null
@@ -1,964 +0,0 @@
-package api
-
-import (
- "containr/internal/database"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "path"
- "regexp"
- "sort"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "gopkg.in/yaml.v3"
-)
-
-var composeVariablePattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|-|:\?|\?)([^}]*))?\}`)
-
-var defaultComposePaths = []string{
- "docker-compose.yml",
- "docker-compose.yaml",
- "compose.yml",
- "compose.yaml",
-}
-
-type ServiceTemplate struct {
- ID string `json:"id" db:"id"`
- Name string `json:"name" db:"name"`
- Description string `json:"description" db:"description"`
- Category string `json:"category" db:"category"`
- Logo string `json:"logo" db:"logo"`
- Config string `json:"config" db:"config"`
- Variables string `json:"variables" db:"variables"`
- Screenshots string `json:"screenshots" db:"screenshots"`
- ComposeYAML string `json:"compose_yaml,omitempty" db:"compose_yaml"`
- IsOfficial bool `json:"is_official" db:"is_official"`
- SourceType string `json:"source_type" db:"source_type"`
- SourceRepo string `json:"source_repo,omitempty" db:"source_repo"`
- SourceBranch string `json:"source_branch,omitempty" db:"source_branch"`
- SourcePath string `json:"source_path,omitempty" db:"source_path"`
- SourceURL string `json:"source_url,omitempty" db:"source_url"`
- CreatedBy string `json:"created_by,omitempty" db:"created_by"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-
-type TemplateConfig struct {
- Type string `json:"type"`
- Runtime string `json:"runtime"`
- BuildCommand string `json:"build_command"`
- StartCommand string `json:"start_command"`
- Port int `json:"port"`
- HealthCheck string `json:"health_check"`
- Environment map[string]string `json:"environment"`
- Dockerfile string `json:"dockerfile,omitempty"`
- NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
-}
-
-type ComposeTemplateConfig struct {
- Type string `json:"type"`
- Format string `json:"format"`
- ComposeFile string `json:"compose_file,omitempty"`
- ServiceCount int `json:"service_count"`
- Services []ComposeServiceSummary `json:"services"`
-}
-
-type ComposeServiceSummary struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Image string `json:"image,omitempty"`
- BuildContext string `json:"build_context,omitempty"`
- Command string `json:"command,omitempty"`
- Ports []string `json:"ports,omitempty"`
- Environment map[string]string `json:"environment,omitempty"`
- DependsOn []string `json:"depends_on,omitempty"`
-}
-
-type TemplateVariable struct {
- Key string `json:"key"`
- Label string `json:"label"`
- Default string `json:"default"`
- Required bool `json:"required"`
- Secret bool `json:"secret"`
- Description string `json:"description"`
-}
-
-type ImportGitHubTemplateRequest struct {
- ProviderID string `json:"provider_id"`
- RepoFullName string `json:"repo_full_name"`
- SourceURL string `json:"source_url"`
- Branch string `json:"branch"`
- ComposePath string `json:"compose_path"`
- ManifestPath string `json:"manifest_path"` // Backward-compatible alias for older clients.
-}
-
-type ImportComposeTemplateRequest struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Category string `json:"category"`
- SourceURL string `json:"source_url"`
- ComposeYAML string `json:"compose_yaml" binding:"required"`
-}
-
-type parsedComposeTemplate struct {
- Name string
- Description string
- Category string
- Logo string
- Screenshots []string
- Variables []TemplateVariable
- Config ComposeTemplateConfig
- ComposeYAML string
-}
-
-func handleGetTemplates(c *gin.Context) {
- db := c.MustGet("db").(*database.DB)
- category := c.Query("category")
-
- query := `SELECT id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
- COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
- COALESCE(source_type, CASE WHEN is_official THEN 'official' ELSE 'community' END),
- COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
- COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at
- FROM service_templates`
- args := []interface{}{}
-
- if category != "" {
- query += " WHERE category = $1"
- args = append(args, category)
- }
- query += " ORDER BY is_official DESC, name ASC"
-
- rows, err := db.Query(query, args...)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
- return
- }
- defer rows.Close()
-
- var templates []ServiceTemplate
- for rows.Next() {
- var t ServiceTemplate
- err := rows.Scan(
- &t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables,
- &t.Screenshots, &t.ComposeYAML, &t.IsOfficial, &t.SourceType, &t.SourceRepo, &t.SourceBranch, &t.SourcePath,
- &t.SourceURL, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt,
- )
- if err != nil {
- continue
- }
- templates = append(templates, t)
- }
-
- c.JSON(http.StatusOK, gin.H{"templates": templates})
-}
-
-func handleGetTemplate(c *gin.Context) {
- db := c.MustGet("db").(*database.DB)
- templateID := c.Param("id")
-
- template, err := getTemplateByID(db, templateID)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
- return
- }
-
- var config map[string]interface{}
- _ = json.Unmarshal([]byte(template.Config), &config)
-
- var variables []TemplateVariable
- _ = json.Unmarshal([]byte(template.Variables), &variables)
-
- var screenshots []string
- _ = json.Unmarshal([]byte(template.Screenshots), &screenshots)
-
- c.JSON(http.StatusOK, gin.H{
- "template": template,
- "config": config,
- "variables": variables,
- "screenshots": screenshots,
- })
-}
-
-func handleImportGitHubTemplate(c *gin.Context) {
- userID := c.MustGet("user_id").(string)
- db := c.MustGet("db").(*database.DB)
-
- var req ImportGitHubTemplateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if req.ComposePath == "" && req.ManifestPath != "" {
- req.ComposePath = req.ManifestPath
- }
-
- if repo, branch, composePath, ok := parseGitHubTemplateReference(req.SourceURL); ok {
- if req.RepoFullName == "" {
- req.RepoFullName = repo
- }
- if req.Branch == "" {
- req.Branch = branch
- }
- if req.ComposePath == "" {
- req.ComposePath = composePath
- }
- }
-
- if req.RepoFullName == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub repository is required"})
- return
- }
-
- provider := GitProvider{Name: "github", APIUrl: "https://api.github.com"}
- if req.ProviderID != "" {
- err := db.QueryRow(`
- SELECT id, name, access_token, api_url
- FROM git_providers
- WHERE id = $1 AND user_id = $2
- `, req.ProviderID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
- return
- }
- }
-
- if req.Branch == "" {
- details, err := fetchGitRepositoryDetails(provider.Name, req.RepoFullName, provider.AccessToken, provider.APIUrl)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read repository: " + err.Error()})
- return
- }
- if branch, ok := details["default_branch"].(string); ok && branch != "" {
- req.Branch = branch
- } else {
- req.Branch = "main"
- }
- }
-
- composePath := req.ComposePath
- rawCompose, err := fetchGitHubCompose(provider, req.RepoFullName, req.Branch, composePath)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to fetch Docker Compose file: " + err.Error()})
- return
- }
- if composePath == "" {
- composePath = detectComposePath(rawCompose.path)
- }
-
- parsed, err := parseComposeTemplate(rawCompose.content, composePath, req.RepoFullName)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- sourceURL := req.SourceURL
- if sourceURL == "" {
- sourceURL = "https://github.com/" + req.RepoFullName + "/blob/" + req.Branch + "/" + composePath
- }
-
- template, err := insertComposeTemplate(db, parsed, insertComposeTemplateOptions{
- UserID: userID,
- SourceType: "github",
- SourceRepo: req.RepoFullName,
- SourceBranch: req.Branch,
- SourcePath: composePath,
- SourceURL: sourceURL,
- })
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to import template"})
- return
- }
-
- LogAuditWithRequest(c, "template", template.ID, "import", map[string]interface{}{
- "source": "github",
- "repo": req.RepoFullName,
- "branch": req.Branch,
- "compose_path": composePath,
- })
-
- c.JSON(http.StatusCreated, gin.H{"template": template})
-}
-
-func handleImportComposeTemplate(c *gin.Context) {
- userID := c.MustGet("user_id").(string)
- db := c.MustGet("db").(*database.DB)
-
- var req ImportComposeTemplateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- parsed, err := parseComposeTemplate([]byte(req.ComposeYAML), "docker-compose.yml", req.Name)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if strings.TrimSpace(req.Name) != "" {
- parsed.Name = strings.TrimSpace(req.Name)
- }
- if strings.TrimSpace(req.Description) != "" {
- parsed.Description = strings.TrimSpace(req.Description)
- }
- if strings.TrimSpace(req.Category) != "" {
- parsed.Category = strings.TrimSpace(req.Category)
- }
-
- template, err := insertComposeTemplate(db, parsed, insertComposeTemplateOptions{
- UserID: userID,
- SourceType: "manual",
- SourceURL: req.SourceURL,
- })
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to import template"})
- return
- }
-
- LogAuditWithRequest(c, "template", template.ID, "import", map[string]interface{}{
- "source": "manual",
- })
-
- c.JSON(http.StatusCreated, gin.H{"template": template})
-}
-
-func handleCreateFromTemplate(c *gin.Context) {
- userID := c.MustGet("user_id").(string)
- db := c.MustGet("db").(*database.DB)
-
- templateID := c.Param("id")
-
- var req struct {
- ProjectID string `json:"project_id" binding:"required"`
- Name string `json:"name" binding:"required"`
- Variables map[string]string `json:"variables"`
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- var projectOwner string
- if err := db.QueryRow("SELECT owner_id FROM projects WHERE id = $1", req.ProjectID).Scan(&projectOwner); err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
- return
- }
- if projectOwner != userID {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
- return
- }
-
- template, err := getTemplateByID(db, templateID)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
- return
- }
-
- if strings.TrimSpace(template.ComposeYAML) != "" {
- serviceIDs, err := createServicesFromComposeTemplate(db, req.ProjectID, req.Name, template, req.Variables)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to install template: " + err.Error()})
- return
- }
-
- LogAuditWithRequest(c, "template", templateID, "install", map[string]interface{}{
- "project_id": req.ProjectID,
- "name": req.Name,
- "service_ids": serviceIDs,
- })
-
- firstID := ""
- if len(serviceIDs) > 0 {
- firstID = serviceIDs[0]
- }
- c.JSON(http.StatusCreated, gin.H{
- "service_id": firstID,
- "service_ids": serviceIDs,
- "message": "Template installed from Docker Compose",
- "serviceCount": len(serviceIDs),
- })
- return
- }
-
- serviceID, err := createLegacyTemplateService(db, req.ProjectID, req.Name, template, req.Variables)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
- return
- }
-
- LogAuditWithRequest(c, "service", serviceID, "create", map[string]interface{}{
- "template_id": templateID,
- "name": req.Name,
- })
-
- c.JSON(http.StatusCreated, gin.H{
- "service_id": serviceID,
- "service_ids": []string{serviceID},
- "message": "Service created from template",
- })
-}
-
-type insertComposeTemplateOptions struct {
- UserID string
- SourceType string
- SourceRepo string
- SourceBranch string
- SourcePath string
- SourceURL string
-}
-
-func insertComposeTemplate(db *database.DB, parsed parsedComposeTemplate, opts insertComposeTemplateOptions) (ServiceTemplate, error) {
- configJSON, _ := json.Marshal(parsed.Config)
- variablesJSON, _ := json.Marshal(parsed.Variables)
- screenshotsJSON, _ := json.Marshal(parsed.Screenshots)
- templateID := "compose-" + uuid.New().String()
- now := time.Now()
-
- var template ServiceTemplate
- err := db.QueryRow(
- `INSERT INTO service_templates
- (id, name, description, category, logo, config, variables, screenshots, compose_yaml, is_official,
- source_type, source_repo, source_branch, source_path, source_url, created_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, $10, $11, $12, $13, $14, $15, $16, $16)
- RETURNING id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
- COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
- COALESCE(source_type, ''), COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
- COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at`,
- templateID, parsed.Name, parsed.Description, parsed.Category, parsed.Logo, string(configJSON), string(variablesJSON),
- string(screenshotsJSON), parsed.ComposeYAML, opts.SourceType, opts.SourceRepo, opts.SourceBranch,
- opts.SourcePath, opts.SourceURL, opts.UserID, now,
- ).Scan(
- &template.ID, &template.Name, &template.Description, &template.Category, &template.Logo,
- &template.Config, &template.Variables, &template.Screenshots, &template.ComposeYAML, &template.IsOfficial,
- &template.SourceType, &template.SourceRepo, &template.SourceBranch, &template.SourcePath, &template.SourceURL,
- &template.CreatedBy, &template.CreatedAt, &template.UpdatedAt,
- )
-
- return template, err
-}
-
-func getTemplateByID(db *database.DB, templateID string) (ServiceTemplate, error) {
- var template ServiceTemplate
- err := db.QueryRow(
- `SELECT id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
- COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
- COALESCE(source_type, CASE WHEN is_official THEN 'official' ELSE 'community' END),
- COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
- COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at
- FROM service_templates WHERE id = $1`,
- templateID,
- ).Scan(
- &template.ID, &template.Name, &template.Description, &template.Category, &template.Logo,
- &template.Config, &template.Variables, &template.Screenshots, &template.ComposeYAML, &template.IsOfficial,
- &template.SourceType, &template.SourceRepo, &template.SourceBranch, &template.SourcePath,
- &template.SourceURL, &template.CreatedBy, &template.CreatedAt, &template.UpdatedAt,
- )
- return template, err
-}
-
-type fetchedComposeFile struct {
- path string
- content []byte
-}
-
-func fetchGitHubCompose(provider GitProvider, repoFullName, branch, composePath string) (fetchedComposeFile, error) {
- if strings.TrimSpace(composePath) != "" {
- content, err := fetchGitHubFile(provider.Name, provider.AccessToken, provider.APIUrl, repoFullName, branch, composePath)
- return fetchedComposeFile{path: composePath, content: content}, err
- }
-
- var lastErr error
- for _, candidate := range defaultComposePaths {
- content, err := fetchGitHubFile(provider.Name, provider.AccessToken, provider.APIUrl, repoFullName, branch, candidate)
- if err == nil {
- return fetchedComposeFile{path: candidate, content: content}, nil
- }
- lastErr = err
- }
- if lastErr != nil {
- return fetchedComposeFile{}, fmt.Errorf("no Compose file found in repository root: %w", lastErr)
- }
- return fetchedComposeFile{}, fmt.Errorf("no Compose file found in repository root")
-}
-
-func detectComposePath(value string) string {
- if value != "" {
- return value
- }
- return "docker-compose.yml"
-}
-
-func parseComposeTemplate(raw []byte, composePath string, fallbackName string) (parsedComposeTemplate, error) {
- var root map[string]interface{}
- if err := yaml.Unmarshal(raw, &root); err != nil {
- return parsedComposeTemplate{}, fmt.Errorf("Docker Compose YAML is not valid")
- }
-
- servicesMap := asMap(root["services"])
- if len(servicesMap) == 0 {
- return parsedComposeTemplate{}, fmt.Errorf("Docker Compose file must include at least one service")
- }
-
- meta := mergeMetadata(asMap(root["x-casaos"]), asMap(root["x-containr"]))
- services := make([]ComposeServiceSummary, 0, len(servicesMap))
- for name, rawService := range servicesMap {
- serviceMap := asMap(rawService)
- service := ComposeServiceSummary{
- Name: name,
- Image: stringValue(serviceMap["image"]),
- BuildContext: buildContextValue(serviceMap["build"]),
- Command: commandValue(serviceMap["command"]),
- Ports: stringSliceValue(serviceMap["ports"]),
- Environment: environmentValue(serviceMap["environment"]),
- DependsOn: stringSliceValue(serviceMap["depends_on"]),
- }
- service.Type = inferComposeServiceType(service)
- services = append(services, service)
- }
- sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
-
- variables := collectComposeVariables(string(raw))
- name := firstTemplateNonEmpty(
- stringFromMetadata(meta, "name", "title"),
- stringValue(root["name"]),
- humanizeTemplateName(fallbackName),
- humanizeTemplateName(strings.TrimSuffix(path.Base(composePath), path.Ext(composePath))),
- )
- description := firstTemplateNonEmpty(
- stringFromMetadata(meta, "description", "desc"),
- fmt.Sprintf("%s stack with %d Compose services.", name, len(services)),
- )
- category := firstTemplateNonEmpty(stringFromMetadata(meta, "category", "class"), inferComposeCategory(services))
- logo := firstTemplateNonEmpty(stringFromMetadata(meta, "icon", "logo", "thumbnail"), "")
- screenshots := stringListFromMetadata(meta, "screenshots", "screenshot", "screenshot_link", "screenshot_links")
-
- return parsedComposeTemplate{
- Name: name,
- Description: description,
- Category: category,
- Logo: logo,
- Screenshots: screenshots,
- Variables: variables,
- ComposeYAML: string(raw),
- Config: ComposeTemplateConfig{
- Type: "compose",
- Format: "docker-compose",
- ComposeFile: composePath,
- ServiceCount: len(services),
- Services: services,
- },
- }, nil
-}
-
-func createServicesFromComposeTemplate(db *database.DB, projectID, installName string, template ServiceTemplate, variables map[string]string) ([]string, error) {
- var config ComposeTemplateConfig
- if err := json.Unmarshal([]byte(template.Config), &config); err != nil {
- return nil, err
- }
- if len(config.Services) == 0 {
- parsed, err := parseComposeTemplate([]byte(template.ComposeYAML), template.SourcePath, template.Name)
- if err != nil {
- return nil, err
- }
- config = parsed.Config
- }
-
- serviceIDs := make([]string, 0, len(config.Services))
- now := time.Now()
- for _, composeService := range config.Services {
- serviceID := uuid.New()
- serviceName := installName
- if len(config.Services) > 1 {
- serviceName = installName + "-" + composeService.Name
- }
- serviceType := composeService.Type
- if serviceType == "" {
- serviceType = "web"
- }
- image := substituteComposeVariables(firstTemplateNonEmpty(composeService.Image, composeService.BuildContext), variables)
- command := substituteComposeVariables(composeService.Command, variables)
- cpu := "0.5"
- memory := "512Mi"
- if serviceType == "database" {
- cpu = "1"
- memory = "1Gi"
- }
-
- _, err := db.Exec(
- `INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
- serviceID, projectID, serviceName, serviceType, "stopped", image, command, "production", cpu, memory, now, now,
- )
- if err != nil {
- return serviceIDs, err
- }
-
- for key, value := range composeService.Environment {
- resolved := substituteComposeVariables(value, variables)
- if explicit, ok := variables[key]; ok {
- resolved = explicit
- }
- _, _ = db.Exec(
- `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
- ON CONFLICT (service_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
- uuid.New(), serviceID, key, resolved, isSecretVariable(key), now, now,
- )
- }
-
- serviceIDs = append(serviceIDs, serviceID.String())
- }
-
- return serviceIDs, nil
-}
-
-func createLegacyTemplateService(db *database.DB, projectID, serviceName string, template ServiceTemplate, variables map[string]string) (string, error) {
- var config TemplateConfig
- _ = json.Unmarshal([]byte(template.Config), &config)
-
- envVars := make(map[string]string)
- for key, value := range config.Environment {
- envVars[key] = value
- }
- for key, value := range variables {
- envVars[key] = value
- }
-
- envVarsJSON, _ := json.Marshal(envVars)
- serviceID := uuid.New()
- now := time.Now()
-
- _, err := db.Exec(
- `INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
- serviceID, projectID, serviceName, config.Type, "stopped", config.Runtime, config.StartCommand,
- string(envVarsJSON), "0.5", "512Mi", now, now,
- )
- if err != nil {
- return "", err
- }
- return serviceID.String(), nil
-}
-
-func collectComposeVariables(raw string) []TemplateVariable {
- matches := composeVariablePattern.FindAllStringSubmatch(raw, -1)
- seen := make(map[string]TemplateVariable)
- for _, match := range matches {
- key := match[1]
- defaultValue := ""
- required := true
- if len(match) > 3 && match[3] != "" {
- defaultValue = match[3]
- required = false
- }
- if existing, ok := seen[key]; ok {
- if existing.Default == "" && defaultValue != "" {
- existing.Default = defaultValue
- existing.Required = false
- seen[key] = existing
- }
- continue
- }
- seen[key] = TemplateVariable{
- Key: key,
- Label: humanizeTemplateName(key),
- Default: defaultValue,
- Required: required,
- Secret: isSecretVariable(key),
- }
- }
-
- keys := make([]string, 0, len(seen))
- for key := range seen {
- keys = append(keys, key)
- }
- sort.Strings(keys)
-
- variables := make([]TemplateVariable, 0, len(keys))
- for _, key := range keys {
- variables = append(variables, seen[key])
- }
- return variables
-}
-
-func substituteComposeVariables(value string, variables map[string]string) string {
- return composeVariablePattern.ReplaceAllStringFunc(value, func(match string) string {
- parts := composeVariablePattern.FindStringSubmatch(match)
- if len(parts) < 2 {
- return match
- }
- if variables != nil {
- if explicit, ok := variables[parts[1]]; ok {
- return explicit
- }
- }
- if len(parts) > 3 {
- return parts[3]
- }
- return ""
- })
-}
-
-func parseGitHubTemplateReference(rawURL string) (repoFullName, branch, composePath string, ok bool) {
- if strings.TrimSpace(rawURL) == "" {
- return "", "", "", false
- }
- parsed, err := url.Parse(rawURL)
- if err != nil {
- return "", "", "", false
- }
-
- host := strings.ToLower(parsed.Host)
- parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
- if host == "github.com" && len(parts) >= 2 {
- repoFullName = parts[0] + "/" + parts[1]
- if len(parts) >= 5 && (parts[2] == "blob" || parts[2] == "tree") {
- branch = parts[3]
- composePath = strings.Join(parts[4:], "/")
- }
- return repoFullName, branch, composePath, true
- }
- if host == "raw.githubusercontent.com" && len(parts) >= 4 {
- repoFullName = parts[0] + "/" + parts[1]
- branch = parts[2]
- composePath = strings.Join(parts[3:], "/")
- return repoFullName, branch, composePath, true
- }
-
- return "", "", "", false
-}
-
-func asMap(value interface{}) map[string]interface{} {
- switch typed := value.(type) {
- case map[string]interface{}:
- return typed
- case map[interface{}]interface{}:
- result := make(map[string]interface{}, len(typed))
- for key, value := range typed {
- result[fmt.Sprint(key)] = value
- }
- return result
- default:
- return map[string]interface{}{}
- }
-}
-
-func mergeMetadata(primary, secondary map[string]interface{}) map[string]interface{} {
- result := map[string]interface{}{}
- for key, value := range primary {
- result[strings.ToLower(key)] = value
- }
- for key, value := range secondary {
- result[strings.ToLower(key)] = value
- }
- return result
-}
-
-func stringFromMetadata(metadata map[string]interface{}, keys ...string) string {
- for _, key := range keys {
- if value := stringValue(metadata[strings.ToLower(key)]); value != "" {
- return value
- }
- }
- return ""
-}
-
-func stringListFromMetadata(metadata map[string]interface{}, keys ...string) []string {
- for _, key := range keys {
- values := stringSliceValue(metadata[strings.ToLower(key)])
- if len(values) > 0 {
- return values
- }
- }
- return []string{}
-}
-
-func stringValue(value interface{}) string {
- switch typed := value.(type) {
- case string:
- return strings.TrimSpace(typed)
- case int, int64, float64, bool:
- return strings.TrimSpace(fmt.Sprint(typed))
- default:
- return ""
- }
-}
-
-func stringSliceValue(value interface{}) []string {
- switch typed := value.(type) {
- case []string:
- return typed
- case []interface{}:
- result := make([]string, 0, len(typed))
- for _, item := range typed {
- if text := stringValue(item); text != "" {
- result = append(result, text)
- }
- }
- return result
- case map[string]interface{}:
- result := make([]string, 0, len(typed))
- for key := range typed {
- result = append(result, key)
- }
- sort.Strings(result)
- return result
- case map[interface{}]interface{}:
- return stringSliceValue(asMap(typed))
- case string:
- if strings.TrimSpace(typed) == "" {
- return []string{}
- }
- return []string{strings.TrimSpace(typed)}
- default:
- return []string{}
- }
-}
-
-func environmentValue(value interface{}) map[string]string {
- result := map[string]string{}
- switch typed := value.(type) {
- case map[string]interface{}:
- for key, value := range typed {
- result[key] = stringValue(value)
- }
- case map[interface{}]interface{}:
- return environmentValue(asMap(typed))
- case []interface{}:
- for _, item := range typed {
- text := stringValue(item)
- if text == "" {
- continue
- }
- key, value, ok := strings.Cut(text, "=")
- if ok {
- result[key] = value
- } else {
- result[text] = "${" + text + "}"
- }
- }
- }
- return result
-}
-
-func buildContextValue(value interface{}) string {
- switch typed := value.(type) {
- case string:
- return strings.TrimSpace(typed)
- case map[string]interface{}:
- return firstTemplateNonEmpty(stringValue(typed["context"]), stringValue(typed["dockerfile"]))
- case map[interface{}]interface{}:
- return buildContextValue(asMap(typed))
- default:
- return ""
- }
-}
-
-func commandValue(value interface{}) string {
- switch typed := value.(type) {
- case string:
- return strings.TrimSpace(typed)
- case []interface{}:
- parts := make([]string, 0, len(typed))
- for _, item := range typed {
- if text := stringValue(item); text != "" {
- parts = append(parts, text)
- }
- }
- return strings.Join(parts, " ")
- default:
- return ""
- }
-}
-
-func inferComposeServiceType(service ComposeServiceSummary) string {
- lower := strings.ToLower(service.Name + " " + service.Image)
- for _, marker := range []string{"postgres", "mysql", "mariadb", "mongo", "redis", "clickhouse", "influxdb"} {
- if strings.Contains(lower, marker) {
- return "database"
- }
- }
- if len(service.Ports) == 0 {
- return "worker"
- }
- return "web"
-}
-
-func inferComposeCategory(services []ComposeServiceSummary) string {
- if len(services) == 1 && services[0].Type == "database" {
- return "database"
- }
- for _, service := range services {
- if service.Type == "web" {
- return "web"
- }
- }
- return "community"
-}
-
-func isSecretVariable(key string) bool {
- upper := strings.ToUpper(key)
- for _, marker := range []string{"PASSWORD", "SECRET", "TOKEN", "API_KEY", "PRIVATE_KEY", "KEY_BASE"} {
- if strings.Contains(upper, marker) {
- return true
- }
- }
- return false
-}
-
-func humanizeTemplateName(value string) string {
- value = strings.TrimSpace(value)
- value = strings.TrimSuffix(value, ".git")
- if strings.Contains(value, "/") {
- parts := strings.Split(value, "/")
- value = parts[len(parts)-1]
- }
- value = strings.ReplaceAll(value, "_", " ")
- value = strings.ReplaceAll(value, "-", " ")
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- words := strings.Fields(value)
- for index, word := range words {
- if len(word) == 0 {
- continue
- }
- words[index] = strings.ToUpper(word[:1]) + word[1:]
- }
- return strings.Join(words, " ")
-}
-
-func firstTemplateNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
-
-func SeedTemplates() []ServiceTemplate {
- templates := []ServiceTemplate{
- {
- ID: "tpl-postgres-compose",
- Name: "PostgreSQL",
- Description: "Single-service PostgreSQL Compose template.",
- Category: "database",
- Logo: "https://cdn.simpleicons.org/postgresql",
- Config: `{"type":"compose","format":"docker-compose","service_count":1,"services":[{"name":"postgres","type":"database","image":"postgres:16","ports":["${POSTGRES_PORT:-5432}:5432"],"environment":{"POSTGRES_USER":"${POSTGRES_USER:-postgres}","POSTGRES_PASSWORD":"${POSTGRES_PASSWORD}","POSTGRES_DB":"${POSTGRES_DB:-app}"}}]}`,
- Variables: `[{"key":"POSTGRES_DB","label":"Postgres Db","default":"app","required":false,"secret":false},{"key":"POSTGRES_PASSWORD","label":"Postgres Password","default":"","required":true,"secret":true},{"key":"POSTGRES_PORT","label":"Postgres Port","default":"5432","required":false,"secret":false},{"key":"POSTGRES_USER","label":"Postgres User","default":"postgres","required":false,"secret":false}]`,
- Screenshots: `[]`,
- ComposeYAML: "services:\n postgres:\n image: postgres:16\n ports:\n - \"${POSTGRES_PORT:-5432}:5432\"\n environment:\n POSTGRES_USER: \"${POSTGRES_USER:-postgres}\"\n POSTGRES_PASSWORD: \"${POSTGRES_PASSWORD}\"\n POSTGRES_DB: \"${POSTGRES_DB:-app}\"\n",
- IsOfficial: true,
- SourceType: "official",
- },
- }
- return templates
-}
diff --git a/internal/api/variables.go b/internal/api/variables.go
deleted file mode 100644
index 32a169b..0000000
--- a/internal/api/variables.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package api
-
-import (
- "containr/internal/database"
- "net/http"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
-)
-
-type EnvironmentVariable struct {
- ID uuid.UUID `json:"id" db:"id"`
- ServiceID uuid.UUID `json:"service_id" db:"service_id"`
- Key string `json:"key" db:"key"`
- Value string `json:"value" db:"value"`
- IsSecret bool `json:"is_secret" db:"is_secret"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-
-type UpdateVariablesRequest struct {
- Variables []VariableInput `json:"variables" binding:"required"`
-}
-
-type VariableInput struct {
- Key string `json:"key" binding:"required"`
- Value string `json:"value"`
- IsSecret bool `json:"is_secret"`
-}
-
-func handleGetVariables(c *gin.Context) {
- db, exists := c.Get("db")
- if !exists {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
- return
- }
-
- serviceIDStr := c.Param("id")
- serviceID, err := uuid.Parse(serviceIDStr)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
- return
- }
-
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
- return
- }
-
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
- return
- }
-
- rows, err := db.(*database.DB).Query(
- `SELECT id, service_id, key, value, is_secret, created_at, updated_at
- FROM environment_variables
- WHERE service_id = $1
- ORDER BY key ASC`,
- serviceID,
- )
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
- return
- }
- defer rows.Close()
-
- var variables []EnvironmentVariable
- for rows.Next() {
- var v EnvironmentVariable
- err := rows.Scan(
- &v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
- )
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
- return
- }
- if v.IsSecret {
- v.Value = "********"
- }
- variables = append(variables, v)
- }
-
- c.JSON(http.StatusOK, gin.H{"variables": variables})
-}
-
-func handleUpdateVariables(c *gin.Context) {
- db, exists := c.Get("db")
- if !exists {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
- return
- }
-
- serviceIDStr := c.Param("id")
- serviceID, err := uuid.Parse(serviceIDStr)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
- return
- }
-
- var req UpdateVariablesRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
- return
- }
-
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
- return
- }
-
- tx, err := db.(*database.DB).Begin()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
- return
- }
- defer tx.Rollback()
-
- _, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
- return
- }
-
- now := time.Now()
- for _, v := range req.Variables {
- varID := uuid.New()
- _, err = tx.Exec(
- `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
- varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
- )
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
- return
- }
- }
-
- if err = tx.Commit(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
- return
- }
-
- rows, err := db.(*database.DB).Query(
- `SELECT id, service_id, key, value, is_secret, created_at, updated_at
- FROM environment_variables
- WHERE service_id = $1
- ORDER BY key ASC`,
- serviceID,
- )
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
- return
- }
- defer rows.Close()
-
- var variables []EnvironmentVariable
- for rows.Next() {
- var v EnvironmentVariable
- err := rows.Scan(
- &v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
- )
- if err != nil {
- continue
- }
- if v.IsSecret {
- v.Value = "********"
- }
- variables = append(variables, v)
- }
-
- c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
-}
diff --git a/internal/api/websocket.go b/internal/api/websocket.go
deleted file mode 100644
index 611ce89..0000000
--- a/internal/api/websocket.go
+++ /dev/null
@@ -1,270 +0,0 @@
-package api
-
-import (
- "encoding/json"
- "log"
- "net/http"
- "sync"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/gorilla/websocket"
-)
-
-var upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
- CheckOrigin: func(r *http.Request) bool {
- return true
- },
-}
-
-type WebSocketClient struct {
- ID string
- UserID string
- Conn *websocket.Conn
- Channels map[string]bool
- Send chan []byte
-}
-
-type WebSocketMessage struct {
- Type string `json:"type"`
- Channel string `json:"channel"`
- Data interface{} `json:"data"`
- Timestamp time.Time `json:"timestamp"`
-}
-
-type WebSocketHub struct {
- clients map[string]*WebSocketClient
- broadcast chan *WebSocketMessage
- register chan *WebSocketClient
- unregister chan *WebSocketClient
- mu sync.RWMutex
-}
-
-var wsHub = &WebSocketHub{
- clients: make(map[string]*WebSocketClient),
- broadcast: make(chan *WebSocketMessage, 100),
- register: make(chan *WebSocketClient),
- unregister: make(chan *WebSocketClient),
-}
-
-func init() {
- go wsHub.run()
-}
-
-func (h *WebSocketHub) run() {
- for {
- select {
- case client := <-h.register:
- h.mu.Lock()
- h.clients[client.ID] = client
- h.mu.Unlock()
- log.Printf("WebSocket client connected: %s", client.ID)
-
- case client := <-h.unregister:
- h.mu.Lock()
- if _, ok := h.clients[client.ID]; ok {
- delete(h.clients, client.ID)
- close(client.Send)
- }
- h.mu.Unlock()
- log.Printf("WebSocket client disconnected: %s", client.ID)
-
- case message := <-h.broadcast:
- h.mu.RLock()
- data, err := json.Marshal(message)
- if err != nil {
- log.Printf("Error marshaling WebSocket message: %v", err)
- h.mu.RUnlock()
- continue
- }
-
- for _, client := range h.clients {
- if client.Channels[message.Channel] || message.Channel == "all" {
- select {
- case client.Send <- data:
- default:
- close(client.Send)
- delete(h.clients, client.ID)
- }
- }
- }
- h.mu.RUnlock()
- }
- }
-}
-
-func (h *WebSocketHub) Broadcast(channel string, msgType string, data interface{}) {
- message := &WebSocketMessage{
- Type: msgType,
- Channel: channel,
- Data: data,
- Timestamp: time.Now(),
- }
- h.broadcast <- message
-}
-
-func (h *WebSocketHub) BroadcastToUser(userID string, msgType string, data interface{}) {
- h.mu.RLock()
- defer h.mu.RUnlock()
-
- message := &WebSocketMessage{
- Type: msgType,
- Channel: "user:" + userID,
- Data: data,
- Timestamp: time.Now(),
- }
-
- messageBytes, err := json.Marshal(message)
- if err != nil {
- return
- }
-
- for _, client := range h.clients {
- if client.UserID == userID {
- select {
- case client.Send <- messageBytes:
- default:
- }
- }
- }
-}
-
-func handleWebSocket(c *gin.Context) {
- conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
- if err != nil {
- log.Printf("WebSocket upgrade error: %v", err)
- return
- }
-
- userID, exists := c.Get("user_id")
- if !exists {
- conn.Close()
- return
- }
-
- client := &WebSocketClient{
- ID: generateClientID(),
- UserID: userID.(string),
- Conn: conn,
- Channels: make(map[string]bool),
- Send: make(chan []byte, 256),
- }
-
- wsHub.register <- client
-
- go client.writePump()
- go client.readPump()
-}
-
-func (c *WebSocketClient) readPump() {
- defer func() {
- wsHub.unregister <- c
- c.Conn.Close()
- }()
-
- c.Conn.SetReadLimit(512)
- c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
-
- for {
- _, message, err := c.Conn.ReadMessage()
- if err != nil {
- break
- }
-
- var msg struct {
- Action string `json:"action"`
- Channel string `json:"channel"`
- }
- if err := json.Unmarshal(message, &msg); err != nil {
- continue
- }
-
- switch msg.Action {
- case "subscribe":
- c.Channels[msg.Channel] = true
- case "unsubscribe":
- delete(c.Channels, msg.Channel)
- }
-
- c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
- }
-}
-
-func (c *WebSocketClient) writePump() {
- ticker := time.NewTicker(30 * time.Second)
- defer func() {
- ticker.Stop()
- c.Conn.Close()
- }()
-
- for {
- select {
- case message, ok := <-c.Send:
- c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if !ok {
- c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
- return
- }
-
- w, err := c.Conn.NextWriter(websocket.TextMessage)
- if err != nil {
- return
- }
- w.Write(message)
-
- n := len(c.Send)
- for i := 0; i < n; i++ {
- w.Write([]byte{'\n'})
- w.Write(<-c.Send)
- }
-
- if err := w.Close(); err != nil {
- return
- }
- case <-ticker.C:
- c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
- return
- }
- }
- }
-}
-
-func generateClientID() string {
- return time.Now().Format("20060102150405") + "-" + randomString(8)
-}
-
-func randomString(n int) string {
- const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
- b := make([]byte, n)
- for i := range b {
- b[i] = letters[time.Now().Nanosecond()%len(letters)]
- }
- return string(b)
-}
-
-func BroadcastServiceUpdate(serviceID string, data interface{}) {
- wsHub.Broadcast("service:"+serviceID, "service_update", data)
-}
-
-func BroadcastDeploymentUpdate(deploymentID string, data interface{}) {
- wsHub.Broadcast("deployment:"+deploymentID, "deployment_update", data)
-}
-
-func BroadcastBuildUpdate(buildID string, data interface{}) {
- wsHub.Broadcast("build:"+buildID, "build_update", data)
-}
-
-func BroadcastMetricsUpdate(serviceID string, data interface{}) {
- wsHub.Broadcast("metrics:"+serviceID, "metrics_update", data)
-}
-
-func BroadcastScalingEvent(serviceID string, data interface{}) {
- wsHub.Broadcast("scaling:"+serviceID, "scaling_event", data)
-}
-
-func NotifyUser(userID string, notificationType string, data interface{}) {
- wsHub.BroadcastToUser(userID, notificationType, data)
-}
diff --git a/internal/apwhy/analytics/umami.go b/internal/apwhy/analytics/umami.go
deleted file mode 100644
index 5ed8d56..0000000
--- a/internal/apwhy/analytics/umami.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package analytics
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "time"
-)
-
-type Client struct {
- BaseURL string
- APIKey string
- WebsiteID string
- HTTP *http.Client
-}
-
-func NewClient(baseURL, apiKey, websiteID string) *Client {
- return &Client{
- BaseURL: baseURL,
- APIKey: apiKey,
- WebsiteID: websiteID,
- HTTP: &http.Client{
- Timeout: 10 * time.Second,
- },
- }
-}
-
-func (c *Client) Enabled() bool {
- return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
-}
-
-func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
- if !c.Enabled() {
- return map[string]any{
- "enabled": false,
- }, nil
- }
-
- u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
- if err != nil {
- return nil, err
- }
-
- query := u.Query()
- query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
- query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
- u.RawQuery = query.Encode()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", "Bearer "+c.APIKey)
- req.Header.Set("Accept", "application/json")
-
- res, err := c.HTTP.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
-
- if res.StatusCode >= 400 {
- return nil, fmt.Errorf("umami returned %d", res.StatusCode)
- }
-
- var payload map[string]any
- if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
- return nil, err
- }
- payload["enabled"] = true
- return payload, nil
-}
diff --git a/internal/apwhy/api/server.go b/internal/apwhy/api/server.go
deleted file mode 100644
index 350cf76..0000000
--- a/internal/apwhy/api/server.go
+++ /dev/null
@@ -1,2694 +0,0 @@
-package api
-
-import (
- "context"
- "database/sql"
- "embed"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "mime"
- "net/http"
- "net/url"
- "os"
- "path"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "apwhy/internal/analytics"
- "apwhy/internal/auth"
- "apwhy/internal/config"
- "apwhy/internal/gateway"
- "apwhy/internal/storage"
-)
-
-//go:embed static/*
-var staticFS embed.FS
-
-type Server struct {
- Store *storage.Store
- Cfg config.Config
- Umami *analytics.Client
- proxyClient *http.Client
-
- rpmMu sync.Mutex
- rpmUsage map[string]rpmWindow
-
- loginMu sync.Mutex
- loginAttempts map[string]loginWindow
-}
-
-type rpmWindow struct {
- WindowStart time.Time
- Count int
-}
-
-type loginWindow struct {
- WindowStart time.Time
- Count int
-}
-
-type authContext struct {
- UserID string
- Email string
- Enabled bool
- ForcePasswordReset bool
- Roles []string
- Permissions map[string]bool
- SessionID string
-}
-
-func NewServer(store *storage.Store, cfg config.Config) *Server {
- return &Server{
- Store: store,
- Cfg: cfg,
- Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID),
- proxyClient: &http.Client{
- Timeout: cfg.DefaultServiceTimeout,
- },
- rpmUsage: map[string]rpmWindow{},
- loginAttempts: map[string]loginWindow{},
- }
-}
-
-func (s *Server) Handler() http.Handler {
- mux := http.NewServeMux()
-
- mux.HandleFunc("GET /health", s.handleHealth)
-
- mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus)
- mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner)
-
- mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin)
- mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
- mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh)
- mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe))
- mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword))
-
- mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList))
- mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate))
- mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch))
-
- mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList))
- mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate))
- mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch))
- mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList))
-
- mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList))
- mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate))
- mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch))
- mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate))
-
- mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList))
- mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate))
- mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch))
- mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate))
-
- mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList))
- mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate))
- mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch))
-
- mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps))
- mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic))
- mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents))
-
- mux.HandleFunc("/", s.handleGatewayOrUI)
-
- return s.withSecurityHeaders(s.withLogging(mux))
-}
-
-func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Frame-Options", "DENY")
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
- next.ServeHTTP(w, r)
- })
-}
-
-func (s *Server) withLogging(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- next.ServeHTTP(w, r)
- _ = s.logMetric("http_request", 1, map[string]any{
- "method": r.Method,
- "path": r.URL.Path,
- "ms": time.Since(start).Milliseconds(),
- })
- })
-}
-
-func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- ctx, err := s.resolveAuth(r)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required")
- return
- }
- if !ctx.Enabled {
- writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
- return
- }
- if ctx.ForcePasswordReset && !allowForceReset {
- writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required")
- return
- }
- if requiredPermission != "" && !ctx.Permissions[requiredPermission] {
- writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions")
- return
- }
- handler(w, r, ctx)
- }
-}
-
-func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "status": "ok",
- "name": "APwhy",
- "database": "sqlite",
- "generatedAt": time.Now().UTC().Format(time.RFC3339),
- },
- })
-}
-
-func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) {
- hasUsers := s.hasUsers(r.Context())
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "hasUsers": hasUsers,
- "registrationOpen": !hasUsers,
- },
- })
-}
-
-func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) {
- if s.hasUsers(r.Context()) {
- writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed")
- return
- }
-
- var input struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- input.Email = strings.ToLower(strings.TrimSpace(input.Email))
- if input.Email == "" || !strings.Contains(input.Email, "@") {
- writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
- return
- }
-
- hash, err := auth.HashPassword(input.Password)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
- return
- }
-
- now := storage.NowISO()
- userID, _ := auth.RandomID("usr")
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
- VALUES (?, ?, ?, 1, 0, ?, ?)
- `, userID, input.Email, hash, now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
- return
- }
-
- ownerRoleID := ""
- if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found")
- return
- }
-
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role")
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration")
- return
- }
-
- s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email})
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": userID,
- "email": input.Email,
- "roles": []string{"owner"},
- },
- })
-}
-
-func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
- var input struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- email := strings.ToLower(strings.TrimSpace(input.Email))
- if email == "" || input.Password == "" {
- writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required")
- return
- }
-
- if s.isLoginRateLimited(r, email) {
- writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts")
- return
- }
-
- var user struct {
- ID string
- Email string
- PasswordHash string
- Enabled int
- ForcePasswordReset int
- }
- err := s.Store.DB.QueryRowContext(r.Context(), `
- SELECT id, email, password_hash, enabled, force_password_reset
- FROM users
- WHERE email = ?
- `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset)
- if err != nil {
- s.markLoginAttempt(r, email)
- writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
- return
- }
- if user.Enabled != 1 {
- writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
- return
- }
-
- ok, err := auth.VerifyPassword(user.PasswordHash, input.Password)
- if err != nil || !ok {
- s.markLoginAttempt(r, email)
- writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
- return
- }
-
- s.clearLoginAttempts(r, email)
-
- sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session")
- return
- }
-
- now := storage.NowISO()
- _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID)
- _ = sessionID
-
- s.setAuthCookies(w, accessToken, refreshToken)
-
- ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken))
- if ctx == nil {
- writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile")
- return
- }
-
- s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email})
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "user": map[string]any{
- "id": ctx.UserID,
- "email": ctx.Email,
- "roles": ctx.Roles,
- "permissions": keysOfMap(ctx.Permissions),
- "forcePasswordReset": ctx.ForcePasswordReset,
- },
- },
- })
-}
-
-func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
- accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie)
- refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie)
-
- now := storage.NowISO()
- if accessCookie != nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ?
- `, now, now, auth.HashToken(accessCookie.Value))
- }
- if refreshCookie != nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ?
- `, now, now, auth.HashToken(refreshCookie.Value))
- }
-
- s.clearAuthCookies(w)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
- refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie)
- if err != nil || strings.TrimSpace(refreshCookie.Value) == "" {
- writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing")
- return
- }
-
- refreshHash := auth.HashToken(refreshCookie.Value)
-
- var session struct {
- ID string
- UserID string
- ExpiresAt string
- RevokedAt sql.NullString
- }
- err = s.Store.DB.QueryRowContext(r.Context(), `
- SELECT id, user_id, refresh_expires_at, revoked_at
- FROM sessions
- WHERE refresh_token_hash = ?
- `, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid")
- return
- }
- if session.RevokedAt.Valid {
- writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked")
- return
- }
- expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt)
- if time.Now().After(expiresAt) {
- writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired")
- return
- }
-
- accessToken, _ := auth.RandomToken(32)
- newRefreshToken, _ := auth.RandomToken(48)
- accessHash := auth.HashToken(accessToken)
- refreshHashNew := auth.HashToken(newRefreshToken)
-
- accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
- refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions
- SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ?
- WHERE id = ?
- `, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session")
- return
- }
-
- s.setAuthCookies(w, accessToken, newRefreshToken)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": ctx.UserID,
- "email": ctx.Email,
- "roles": ctx.Roles,
- "permissions": keysOfMap(ctx.Permissions),
- "forcePasswordReset": ctx.ForcePasswordReset,
- },
- })
-}
-
-func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- NewPassword string `json:"newPassword"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- hash, err := auth.HashPassword(input.NewPassword)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
- return
- }
-
- now := storage.NowISO()
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ?
- `, hash, now, ctx.UserID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at,
- COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs
- FROM users u
- LEFT JOIN user_roles ur ON ur.user_id = u.id
- LEFT JOIN roles r ON r.id = ur.role_id
- GROUP BY u.id
- ORDER BY u.created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users")
- return
- }
- defer rows.Close()
-
- items := make([]map[string]any, 0)
- for rows.Next() {
- var id, email, createdAt, updatedAt, roleSlugs string
- var enabled, forceReset int
- if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil {
- continue
- }
- roles := []string{}
- if strings.TrimSpace(roleSlugs) != "" {
- roles = strings.Split(roleSlugs, ",")
- }
- items = append(items, map[string]any{
- "id": id,
- "email": email,
- "enabled": enabled == 1,
- "forcePasswordReset": forceReset == 1,
- "roles": roles,
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Email string `json:"email"`
- RoleIDs []string `json:"roleIds"`
- RoleSlugs []string `json:"roleSlugs"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- email := strings.ToLower(strings.TrimSpace(input.Email))
- if email == "" || !strings.Contains(email, "@") {
- writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
- return
- }
-
- roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
- return
- }
- if len(roleIDs) == 0 {
- viewerID, err := s.roleIDBySlug(r.Context(), "viewer")
- if err == nil {
- roleIDs = []string{viewerID}
- }
- }
-
- tempPassword, _ := auth.RandomPassword(16)
- passwordHash, err := auth.HashPassword(tempPassword)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password")
- return
- }
-
- inviteToken, _ := auth.RandomToken(32)
- inviteHash := auth.HashToken(inviteToken)
- now := storage.NowISO()
- expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)
-
- userID, _ := auth.RandomID("usr")
- inviteID, _ := auth.RandomID("inv")
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
- VALUES (?, ?, ?, 1, 1, ?, ?)
- `, userID, email, passwordHash, now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
- return
- }
-
- for _, roleID := range roleIDs {
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles")
- return
- }
- }
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- `, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite")
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)})
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": userID,
- "email": email,
- "inviteToken": inviteToken,
- "temporaryPass": tempPassword,
- "expiresAt": expiresAt,
- },
- })
-}
-
-func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required")
- return
- }
-
- var input struct {
- Enabled *bool `json:"enabled"`
- RoleIDs []string `json:"roleIds"`
- RoleSlugs []string `json:"roleSlugs"`
- ResetPassword bool `json:"resetPassword"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- now := storage.NowISO()
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- if input.Enabled != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user")
- return
- }
- }
-
- if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 {
- roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
- return
- }
- _, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles")
- return
- }
- for _, roleID := range roleIDs {
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role")
- return
- }
- }
- }
-
- response := map[string]any{}
- if input.ResetPassword {
- tempPassword, _ := auth.RandomPassword(16)
- hash, err := auth.HashPassword(tempPassword)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password")
- return
- }
- _, err = tx.ExecContext(r.Context(), `
- UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ?
- `, hash, now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password")
- return
- }
- response["temporaryPass"] = tempPassword
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response})
-}
-
-func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled,
- COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes,
- r.created_at, r.updated_at
- FROM roles r
- LEFT JOIN role_permissions rp ON rp.role_id = r.id
- LEFT JOIN permissions p ON p.id = rp.permission_id
- GROUP BY r.id
- ORDER BY r.created_at ASC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles")
- return
- }
- defer rows.Close()
-
- roles := []map[string]any{}
- for rows.Next() {
- var id, name, slug, description, permissionCodes, createdAt, updatedAt string
- var isSystem, enabled int
- if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil {
- continue
- }
- codes := []string{}
- if strings.TrimSpace(permissionCodes) != "" {
- codes = strings.Split(permissionCodes, ",")
- sort.Strings(codes)
- }
- roles = append(roles, map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "description": description,
- "isSystem": isSystem == 1,
- "enabled": enabled == 1,
- "permissionCodes": codes,
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles})
- _ = ctx
-}
-
-func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- PermissionCodes []string `json:"permissionCodes"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- name := strings.TrimSpace(input.Name)
- if name == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required")
- return
- }
- slug := slugify(firstNonEmpty(input.Slug, input.Name), "role")
- id, _ := auth.RandomID("role")
- now := storage.NowISO()
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at)
- VALUES (?, ?, ?, ?, 0, 1, ?, ?)
- `, id, name, slug, strings.TrimSpace(input.Description), now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists")
- return
- }
-
- if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
- writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
-}
-
-func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required")
- return
- }
-
- var input struct {
- Name *string `json:"name"`
- Description *string `json:"description"`
- Enabled *bool `json:"enabled"`
- PermissionCodes []string `json:"permissionCodes"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- isSystem := 0
- if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil {
- writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found")
- return
- }
-
- now := storage.NowISO()
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- if input.Name != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name")
- return
- }
- }
- if input.Description != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description")
- return
- }
- }
- if input.Enabled != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status")
- return
- }
- }
-
- if input.PermissionCodes != nil {
- if isSystem == 1 {
- writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked")
- return
- }
- if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
- writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
- return
- }
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, code, name, description string
- if err := rows.Scan(&id, &code, &name, &description); err != nil {
- continue
- }
- items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description})
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services")
- return
- }
- defer rows.Close()
- items := []map[string]any{}
- for rows.Next() {
- item, err := scanService(rows)
- if err == nil {
- items = append(items, item)
- }
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input serviceInput
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- if strings.TrimSpace(input.Name) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required")
- return
- }
- if strings.TrimSpace(input.UpstreamURL) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required")
- return
- }
- routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service"))
- if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
- writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
- return
- }
- if strings.HasPrefix(routePrefix, "/api/") {
- writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API")
- return
- }
-
- now := storage.NowISO()
- id, _ := auth.RandomID("svc")
- slug := slugify(firstNonEmpty(input.Slug, input.Name), "service")
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO services (
- id, name, slug, upstream_url, route_prefix, health_path,
- upstream_auth_header, upstream_auth_value, internal_token,
- enabled, rpm_limit, monthly_quota, request_timeout_ms,
- created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- id,
- strings.TrimSpace(input.Name),
- slug,
- strings.TrimSpace(input.UpstreamURL),
- routePrefix,
- normalizePathPrefix(input.HealthPath, "/health"),
- nullIfEmpty(input.UpstreamAuthHeader),
- nullIfEmpty(input.UpstreamAuthValue),
- nullIfEmpty(input.InternalToken),
- boolToInt(defaultBool(input.Enabled, true)),
- nullInt(input.RPMLimit),
- nullInt(input.MonthlyQuota),
- nullInt(input.RequestTimeoutMS),
- now,
- now,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
-}
-
-func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required")
- return
- }
-
- current, err := s.getServiceByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
- return
- }
-
- var patch serviceInput
- if err := decodeJSON(r.Body, &patch); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- name := firstNonEmpty(patch.Name, asString(current["name"]))
- routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/")
- if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
- writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
- return
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE services SET
- name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?,
- upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?,
- rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ?
- WHERE id = ?
- `,
- name,
- slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"),
- firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])),
- routePrefix,
- normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"),
- nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))),
- nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))),
- nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))),
- boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))),
- nullInt(patch.RPMLimitOr(current["rpmLimit"])),
- nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])),
- nullInt(patch.TimeoutOr(current["requestTimeoutMs"])),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- service, err := s.getServiceByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
- return
- }
-
- timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))
- client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
- target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health")
-
- req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
- if header := asString(service["upstreamAuthHeader"]); header != "" {
- req.Header.Set(header, asString(service["upstreamAuthValue"]))
- }
- if token := asString(service["internalToken"]); token != "" {
- req.Header.Set(s.Cfg.ServiceTokenHeader, token)
- }
- res, err := client.Do(req)
- status := 0
- message := ""
- ok := false
- if err != nil {
- status = http.StatusBadGateway
- message = err.Error()
- } else {
- defer res.Body.Close()
- status = res.StatusCode
- message = fmt.Sprintf("HTTP %d", res.StatusCode)
- ok = res.StatusCode >= 200 && res.StatusCode < 300
- }
-
- validationStatus := "failed"
- if ok {
- validationStatus = "healthy"
- }
-
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE services
- SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
- WHERE id = ?
- `, storage.NowISO(), validationStatus, message, storage.NowISO(), id)
-
- if !ok {
- s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM database_connections
- ORDER BY created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, name, slug, provider, connURL, createdAt, updatedAt string
- var enabled int
- var lastAt, lastStatus, lastMessage sql.NullString
- if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil {
- continue
- }
- items = append(items, map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "provider": provider,
- "target": extractDatabaseTarget(provider, connURL),
- "maskedConnectionUrl": maskDatabaseURL(provider, connURL),
- "enabled": enabled == 1,
- "lastValidationAt": nullString(lastAt),
- "lastValidationStatus": nullString(lastStatus),
- "lastValidationMessage": nullString(lastMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Provider string `json:"provider"`
- ConnectionURL string `json:"connectionUrl"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- provider := normalizeProvider(input.Provider)
- if provider == "" {
- writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
- return
- }
- if strings.TrimSpace(input.ConnectionURL) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required")
- return
- }
-
- now := storage.NowISO()
- id, _ := auth.RandomID("dbc")
- slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database")
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists")
- return
- }
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required")
- return
- }
-
- var input struct {
- Name *string `json:"name"`
- Slug *string `json:"slug"`
- Provider *string `json:"provider"`
- ConnectionURL *string `json:"connectionUrl"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- current, err := s.getDatabaseByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
- return
- }
-
- provider := asString(current["provider"])
- if input.Provider != nil {
- norm := normalizeProvider(*input.Provider)
- if norm == "" {
- writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
- return
- }
- provider = norm
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE database_connections
- SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ?
- WHERE id = ?
- `,
- firstNonEmptyPtr(input.Name, asString(current["name"])),
- slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"),
- provider,
- firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])),
- boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection")
- return
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- database, err := s.getDatabaseByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
- return
- }
-
- provider := asString(database["provider"])
- connectionURL := asString(database["connectionUrl"])
- ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL)
- state := "failed"
- if ok {
- state = "healthy"
- }
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE database_connections
- SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
- WHERE id = ?
- `, storage.NowISO(), state, message, storage.NowISO(), id)
-
- if !ok {
- s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
- _ = ctx
-}
-
-func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at
- FROM api_keys
- ORDER BY created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string
- var enabled int
- var rpm, monthly sql.NullInt64
- var lastUsed sql.NullString
- if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil {
- continue
- }
- allowedServices := []string{}
- _ = json.Unmarshal([]byte(allowed), &allowedServices)
- items = append(items, map[string]any{
- "id": id,
- "name": name,
- "keyPrefix": keyPrefix,
- "plan": plan,
- "allowedServiceIds": allowedServices,
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpm),
- "monthlyQuota": nullIntValue(monthly),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- "lastUsedAt": nullString(lastUsed),
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Plan string `json:"plan"`
- AllowedServiceIDs []string `json:"allowedServiceIds"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- name := strings.TrimSpace(input.Name)
- if name == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required")
- return
- }
- plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro")))
- rpmLimit, monthlyQuota := s.planDefaults(plan)
- if input.RPMLimit != nil {
- rpmLimit = input.RPMLimit
- }
- if input.MonthlyQuota != nil {
- monthlyQuota = input.MonthlyQuota
- }
-
- rawKey, _ := auth.RandomToken(28)
- rawKey = "apy_" + rawKey
- keyHash := auth.HashToken(rawKey)
- id, _ := auth.RandomID("key")
- now := storage.NowISO()
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- id,
- name,
- keyHash,
- rawKey[:12],
- plan,
- mustJSON(input.AllowedServiceIDs),
- boolToInt(defaultBool(input.Enabled, true)),
- nullInt(rpmLimit),
- nullInt(monthlyQuota),
- now,
- now,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key")
- return
- }
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "key": rawKey,
- "item": map[string]any{"id": id, "name": name, "plan": plan},
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required")
- return
- }
- var input struct {
- Name *string `json:"name"`
- Plan *string `json:"plan"`
- AllowedServiceIDs []string `json:"allowedServiceIds"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- current := struct {
- Name string
- Plan string
- Allowed string
- Enabled int
- RPM sql.NullInt64
- Monthly sql.NullInt64
- }{}
- err := s.Store.DB.QueryRowContext(r.Context(), `
- SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota
- FROM api_keys WHERE id = ?
- `, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly)
- if err != nil {
- writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found")
- return
- }
-
- plan := current.Plan
- if input.Plan != nil {
- plan = strings.ToLower(strings.TrimSpace(*input.Plan))
- }
-
- allowed := current.Allowed
- if input.AllowedServiceIDs != nil {
- allowed = mustJSON(input.AllowedServiceIDs)
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE api_keys
- SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ?
- WHERE id = ?
- `,
- firstNonEmptyPtr(input.Name, current.Name),
- plan,
- allowed,
- boolToInt(defaultBool(input.Enabled, current.Enabled == 1)),
- nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))),
- nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key")
- return
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- counts := map[string]int{}
- for _, table := range []string{"users", "services", "database_connections", "api_keys"} {
- var count int
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count)
- counts[table] = count
- }
-
- period := time.Now().UTC().Format("2006-01")
- var monthlyRequests int
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests)
-
- severityRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT severity, COUNT(*) FROM incident_events
- WHERE occurred_at >= datetime('now', '-24 hours')
- GROUP BY severity
- `)
- incidentsBySeverity := map[string]int{}
- if severityRows != nil {
- defer severityRows.Close()
- for severityRows.Next() {
- var severity string
- var count int
- if err := severityRows.Scan(&severity, &count); err == nil {
- incidentsBySeverity[severity] = count
- }
- }
- }
-
- healthRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*)
- FROM services
- GROUP BY status
- `)
- healthCounts := map[string]int{}
- if healthRows != nil {
- defer healthRows.Close()
- for healthRows.Next() {
- var status string
- var count int
- if err := healthRows.Scan(&status, &count); err == nil {
- healthCounts[status] = count
- }
- }
- }
-
- tsRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*)
- FROM metrics_timeseries
- WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours')
- GROUP BY bucket
- ORDER BY bucket ASC
- `)
- hourly := []map[string]any{}
- if tsRows != nil {
- defer tsRows.Close()
- for tsRows.Next() {
- var bucket string
- var count int
- if err := tsRows.Scan(&bucket, &count); err == nil {
- hourly = append(hourly, map[string]any{"bucket": bucket, "value": count})
- }
- }
- }
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "counts": map[string]any{
- "users": counts["users"],
- "services": counts["services"],
- "databases": counts["database_connections"],
- "keys": counts["api_keys"],
- },
- "requests": map[string]any{
- "period": period,
- "total": monthlyRequests,
- "hourly": hourly,
- },
- "incidentsBySeverity": incidentsBySeverity,
- "serviceHealth": healthCounts,
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- from := time.Now().Add(-7 * 24 * time.Hour)
- to := time.Now()
- if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" {
- if parsed, err := time.Parse(time.RFC3339, value); err == nil {
- from = parsed
- }
- }
- if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" {
- if parsed, err := time.Parse(time.RFC3339, value); err == nil {
- to = parsed
- }
- }
-
- traffic, err := s.Umami.FetchTraffic(r.Context(), from, to)
- if err == nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at)
- VALUES ('traffic', ?, ?)
- ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at
- `, mustJSON(traffic), storage.NowISO())
- } else {
- var payload string
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload)
- if payload != "" {
- _ = json.Unmarshal([]byte(payload), &traffic)
- }
- if traffic == nil {
- traffic = map[string]any{"enabled": false, "error": err.Error()}
- }
- }
-
- eventRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*)
- FROM metrics_timeseries
- WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days')
- GROUP BY path
- ORDER BY COUNT(*) DESC
- LIMIT 20
- `)
- events := []map[string]any{}
- if eventRows != nil {
- defer eventRows.Close()
- for eventRows.Next() {
- var page string
- var count int
- if err := eventRows.Scan(&page, &count); err == nil {
- events = append(events, map[string]any{"path": page, "count": count})
- }
- }
- }
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "umami": traffic,
- "clientEvents": events,
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Event string `json:"event"`
- Path string `json:"path"`
- Meta map[string]any `json:"meta"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- labels := map[string]any{
- "event": firstNonEmpty(input.Event, "event"),
- "path": firstNonEmpty(input.Path, "unknown"),
- "meta": input.Meta,
- }
- _ = s.logMetric("client_event", 1, labels)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) {
- if strings.HasPrefix(r.URL.Path, "/api/") {
- writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
- return
- }
-
- uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/")
- uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath)
- if uiBasePath != "/" && uiPathMatched {
- s.serveWeb(w, uiRequestPath)
- return
- }
-
- service, ok := s.matchService(r.Context(), r.URL.Path)
- if ok {
- s.handleProxy(w, r, service)
- return
- }
-
- if uiBasePath == "/" {
- s.serveWeb(w, uiRequestPath)
- return
- }
-
- writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
-}
-
-func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) {
- sub, err := fs.Sub(staticFS, "static")
- if err != nil {
- writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
- return
- }
-
- reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/")
- if reqPath == "." {
- reqPath = ""
- }
- if reqPath == "" {
- reqPath = "index.html"
- }
- if _, err := fs.Stat(sub, reqPath); err != nil {
- reqPath = "index.html"
- }
-
- data, err := fs.ReadFile(sub, reqPath)
- if err != nil {
- writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
- return
- }
-
- if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" {
- w.Header().Set("Content-Type", contentType)
- }
- _, _ = w.Write(data)
-}
-
-func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) {
- secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader))
- if secret == "" {
- writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader))
- return
- }
-
- apiKey, err := s.getAPIKeyBySecret(r.Context(), secret)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true})
- return
- }
- if !asBool(apiKey["enabled"]) {
- writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled")
- return
- }
- if !asBool(service["enabled"]) {
- writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled")
- return
- }
-
- if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) {
- writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true})
- return
- }
-
- period := time.Now().UTC().Format("2006-01")
- serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"])
- keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"])
- serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"])
- keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"])
-
- effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM)
- effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly)
-
- if effectiveMonthly != nil {
- used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
- if used >= *effectiveMonthly {
- writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true})
- return
- }
- }
-
- if effectiveRPM != nil {
- if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok {
- w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
- writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true})
- return
- }
- }
-
- targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery)
- if err != nil {
- writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL")
- return
- }
-
- headers := map[string]string{}
- if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" {
- headers[upstreamHeader] = asString(service["upstreamAuthValue"])
- }
- if token := asString(service["internalToken"]); token != "" {
- headers[s.Cfg.ServiceTokenHeader] = token
- }
-
- r.Header.Del(s.Cfg.APIKeyHeader)
-
- timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond
- client := &http.Client{Timeout: timeout}
-
- statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers)
- if proxyErr != nil {
- status := http.StatusBadGateway
- if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") {
- status = http.StatusGatewayTimeout
- }
- writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error())
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true})
- return
- }
-
- s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
- _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"]))
- _ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode})
-
- if statusCode >= 500 {
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true})
- }
-}
-
-func (s *Server) resolveAuth(r *http.Request) (*authContext, error) {
- accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie)
- if err != nil || strings.TrimSpace(accessCookie.Value) == "" {
- return nil, errors.New("access cookie missing")
- }
- return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value))
-}
-
-func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) {
- var user struct {
- ID string
- Email string
- Enabled int
- ForcePasswordReset int
- SessionID string
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id
- FROM sessions sess
- JOIN users u ON u.id = sess.user_id
- WHERE sess.access_token_hash = ?
- AND sess.revoked_at IS NULL
- AND sess.access_expires_at > ?
- `, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID)
- if err != nil {
- return nil, err
- }
-
- roles, permissions, err := s.userAccess(ctx, user.ID)
- if err != nil {
- return nil, err
- }
-
- return &authContext{
- UserID: user.ID,
- Email: user.Email,
- Enabled: user.Enabled == 1,
- ForcePasswordReset: user.ForcePasswordReset == 1,
- Roles: roles,
- Permissions: permissions,
- SessionID: user.SessionID,
- }, nil
-}
-
-func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) {
- roles := []string{}
- roleRows, err := s.Store.DB.QueryContext(ctx, `
- SELECT r.slug
- FROM user_roles ur
- JOIN roles r ON r.id = ur.role_id
- WHERE ur.user_id = ? AND r.enabled = 1
- `, userID)
- if err != nil {
- return nil, nil, err
- }
- for roleRows.Next() {
- var slug string
- if err := roleRows.Scan(&slug); err == nil {
- roles = append(roles, slug)
- }
- }
- roleRows.Close()
-
- permissions := map[string]bool{}
- permRows, err := s.Store.DB.QueryContext(ctx, `
- SELECT DISTINCT p.code
- FROM user_roles ur
- JOIN role_permissions rp ON rp.role_id = ur.role_id
- JOIN permissions p ON p.id = rp.permission_id
- JOIN roles r ON r.id = ur.role_id
- WHERE ur.user_id = ? AND r.enabled = 1
- `, userID)
- if err != nil {
- return roles, permissions, err
- }
- for permRows.Next() {
- var code string
- if err := permRows.Scan(&code); err == nil {
- permissions[code] = true
- }
- }
- permRows.Close()
-
- return roles, permissions, nil
-}
-
-func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) {
- sessionID, _ := auth.RandomID("ses")
- accessToken, _ := auth.RandomToken(32)
- refreshToken, _ := auth.RandomToken(48)
- accessHash := auth.HashToken(accessToken)
- refreshHash := auth.HashToken(refreshToken)
-
- now := storage.NowISO()
- accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
- refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
-
- _, err := s.Store.DB.ExecContext(ctx, `
- INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now)
- if err != nil {
- return "", "", "", err
- }
-
- return sessionID, accessToken, refreshToken, nil
-}
-
-func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
- accessCookie := &http.Cookie{
- Name: s.Cfg.SessionAccessCookie,
- Value: accessToken,
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: time.Now().Add(s.Cfg.AccessTokenTTL),
- }
- if s.Cfg.CookieDomain != "" {
- accessCookie.Domain = s.Cfg.CookieDomain
- }
-
- refreshCookie := &http.Cookie{
- Name: s.Cfg.SessionRefreshCookie,
- Value: refreshToken,
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: time.Now().Add(s.Cfg.RefreshTokenTTL),
- }
- if s.Cfg.CookieDomain != "" {
- refreshCookie.Domain = s.Cfg.CookieDomain
- }
-
- http.SetCookie(w, accessCookie)
- http.SetCookie(w, refreshCookie)
-}
-
-func (s *Server) clearAuthCookies(w http.ResponseWriter) {
- expired := time.Unix(0, 0)
- for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} {
- cookie := &http.Cookie{
- Name: cookieName,
- Value: "",
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: expired,
- MaxAge: -1,
- }
- if s.Cfg.CookieDomain != "" {
- cookie.Domain = s.Cfg.CookieDomain
- }
- http.SetCookie(w, cookie)
- }
-}
-
-func (s *Server) hasUsers(ctx context.Context) bool {
- var count int
- _ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
- return count > 0
-}
-
-func (s *Server) isLoginRateLimited(r *http.Request, email string) bool {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- entry := s.loginAttempts[key]
- now := time.Now()
- if now.Sub(entry.WindowStart) > 15*time.Minute {
- entry = loginWindow{WindowStart: now, Count: 0}
- }
- return entry.Count >= 10
-}
-
-func (s *Server) markLoginAttempt(r *http.Request, email string) {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- entry := s.loginAttempts[key]
- now := time.Now()
- if now.Sub(entry.WindowStart) > 15*time.Minute {
- entry = loginWindow{WindowStart: now, Count: 0}
- }
- entry.Count++
- s.loginAttempts[key] = entry
-}
-
-func (s *Server) clearLoginAttempts(r *http.Request, email string) {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- delete(s.loginAttempts, key)
-}
-
-func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) {
- resolved := []string{}
- if len(roleIDs) > 0 {
- for _, roleID := range roleIDs {
- id := strings.TrimSpace(roleID)
- if id == "" {
- continue
- }
- var exists int
- if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 {
- return nil, fmt.Errorf("role %s not found", id)
- }
- resolved = append(resolved, id)
- }
- return uniqueStrings(resolved), nil
- }
- for _, slug := range roleSlugs {
- id, err := s.roleIDBySlug(ctx, slug)
- if err != nil {
- return nil, err
- }
- resolved = append(resolved, id)
- }
- return uniqueStrings(resolved), nil
-}
-
-func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) {
- id := ""
- err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id)
- if err != nil {
- return "", fmt.Errorf("role %s not found", slug)
- }
- return id, nil
-}
-
-func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error {
- if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil {
- return err
- }
- now := storage.NowISO()
- for _, code := range permissionCodes {
- permCode := strings.TrimSpace(code)
- if permCode == "" {
- continue
- }
- permID := ""
- if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil {
- return fmt.Errorf("permission not found: %s", permCode)
- }
- if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) {
- row := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM services WHERE id = ?
- `, id)
- return scanServiceRow(row)
-}
-
-func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) {
- var item struct {
- ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string
- Enabled int
- LastAt, LastStatus, LastMessage sql.NullString
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM database_connections
- WHERE id = ?
- `, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, err
- }
- return map[string]any{
- "id": item.ID,
- "name": item.Name,
- "slug": item.Slug,
- "provider": item.Provider,
- "connectionUrl": item.ConnectionURL,
- "target": extractDatabaseTarget(item.Provider, item.ConnectionURL),
- "maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL),
- "enabled": item.Enabled == 1,
- "lastValidationAt": nullString(item.LastAt),
- "lastValidationStatus": nullString(item.LastStatus),
- "lastValidationMessage": nullString(item.LastMessage),
- "createdAt": item.CreatedAt,
- "updatedAt": item.UpdatedAt,
- }, nil
-}
-
-func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) {
- provider = normalizeProvider(provider)
- if provider == "" {
- return false, http.StatusBadRequest, "Unsupported database provider"
- }
-
- timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout)
- defer cancel()
-
- sqlDriver := ""
- driverConn := connectionURL
- switch provider {
- case "sqlite":
- sqlDriver = "sqlite"
- if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" {
- if abs, err := pathAbs(driverConn); err == nil {
- driverConn = "file:" + abs
- }
- }
- case "postgres":
- sqlDriver = "pgx"
- case "mysql":
- sqlDriver = "mysql"
- }
-
- db, err := sql.Open(sqlDriver, driverConn)
- if err != nil {
- return false, http.StatusBadGateway, err.Error()
- }
- defer db.Close()
-
- db.SetConnMaxLifetime(2 * time.Minute)
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
-
- if err := db.PingContext(timeoutCtx); err != nil {
- return false, http.StatusBadGateway, err.Error()
- }
- return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider))
-}
-
-func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) {
- hash := auth.HashToken(secret)
- var item struct {
- ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string
- Enabled int
- RPMLimit, MonthlyQuota sql.NullInt64
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at
- FROM api_keys
- WHERE key_hash = ?
- `, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, err
- }
- allowed := []string{}
- _ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed)
- return map[string]any{
- "id": item.ID,
- "name": item.Name,
- "keyPrefix": item.KeyPrefix,
- "plan": item.Plan,
- "allowedServiceIds": allowed,
- "enabled": item.Enabled == 1,
- "rpmLimit": nullIntValue(item.RPMLimit),
- "monthlyQuota": nullIntValue(item.MonthlyQuota),
- }, nil
-}
-
-func (s *Server) planDefaults(plan string) (*int, *int) {
- p := strings.ToLower(strings.TrimSpace(plan))
- switch p {
- case "free":
- return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota)
- case "business":
- return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota)
- case "enterprise":
- return nil, nil
- default:
- return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota)
- }
-}
-
-func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) {
- rows, err := s.Store.DB.QueryContext(ctx, `
- SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM services
- WHERE enabled = 1
- ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC
- `)
- if err != nil {
- return nil, false
- }
- defer rows.Close()
-
- for rows.Next() {
- item, err := scanService(rows)
- if err != nil {
- continue
- }
- prefix := asString(item["routePrefix"])
- if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
- return item, true
- }
- }
- return nil, false
-}
-
-func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int {
- var count int
- _ = s.Store.DB.QueryRowContext(ctx, `
- SELECT COALESCE(request_count, 0)
- FROM usage_counters
- WHERE api_key_id = ? AND service_id = ? AND period_month = ?
- `, apiKeyID, serviceID, period).Scan(&count)
- return count
-}
-
-func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) {
- _, _ = s.Store.DB.ExecContext(ctx, `
- INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
- VALUES (?, ?, ?, 1, ?)
- ON CONFLICT(api_key_id, service_id, period_month)
- DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at
- `, apiKeyID, serviceID, period, storage.NowISO())
-}
-
-func (s *Server) allowRPM(bucket string, limit int) (bool, int) {
- if limit <= 0 {
- return true, 0
- }
- now := time.Now().UTC()
- windowStart := now.Truncate(time.Minute)
- key := bucket + ":" + windowStart.Format(time.RFC3339)
-
- s.rpmMu.Lock()
- defer s.rpmMu.Unlock()
-
- for k, v := range s.rpmUsage {
- if now.Sub(v.WindowStart) > 2*time.Minute {
- delete(s.rpmUsage, k)
- }
- }
-
- entry := s.rpmUsage[key]
- if entry.WindowStart.IsZero() {
- entry.WindowStart = windowStart
- }
- if entry.Count >= limit {
- retry := int(windowStart.Add(time.Minute).Sub(now).Seconds())
- if retry < 1 {
- retry = 1
- }
- return false, retry
- }
- entry.Count++
- s.rpmUsage[key] = entry
- return true, 0
-}
-
-func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error {
- _, err := s.Store.DB.ExecContext(ctx, `
- INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at)
- VALUES (?, ?, ?, ?, ?, ?, 1, ?)
- `, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO())
- return err
-}
-
-func (s *Server) logMetric(metric string, value float64, labels map[string]any) error {
- _, err := s.Store.DB.Exec(`
- INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
- VALUES (?, ?, ?, ?)
- `, metric, value, mustJSON(labels), storage.NowISO())
- return err
-}
-
-func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) {
- _, _ = s.Store.DB.ExecContext(ctx, `
- INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO())
-}
-
-func scanService(rows *sql.Rows) (map[string]any, error) {
- var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
- var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
- var enabled int
- var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
- if err := rows.Scan(
- &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
- &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
- &lastValidationAt, &lastValidationStatus, &lastValidationMessage,
- &createdAt, &updatedAt,
- ); err != nil {
- return nil, err
- }
- return map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "upstreamUrl": upstreamURL,
- "routePrefix": routePrefix,
- "healthPath": healthPath,
- "upstreamAuthHeader": nullString(upstreamAuthHeader),
- "upstreamAuthValue": nullString(upstreamAuthValue),
- "internalToken": nullString(internalToken),
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpmLimit),
- "monthlyQuota": nullIntValue(monthlyQuota),
- "requestTimeoutMs": nullIntValue(requestTimeoutMS),
- "lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})),
- "lastValidationStatus": nullString(lastValidationStatus),
- "lastValidationMessage": nullString(lastValidationMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- }, nil
-}
-
-func scanServiceRow(row *sql.Row) (map[string]any, error) {
- var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
- var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
- var enabled int
- var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
- if err := row.Scan(
- &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
- &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
- &lastValidationAt, &lastValidationStatus, &lastValidationMessage,
- &createdAt, &updatedAt,
- ); err != nil {
- return nil, err
- }
- return map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "upstreamUrl": upstreamURL,
- "routePrefix": routePrefix,
- "healthPath": healthPath,
- "upstreamAuthHeader": nullString(upstreamAuthHeader),
- "upstreamAuthValue": nullString(upstreamAuthValue),
- "internalToken": nullString(internalToken),
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpmLimit),
- "monthlyQuota": nullIntValue(monthlyQuota),
- "requestTimeoutMs": nullIntValue(requestTimeoutMS),
- "lastValidationAt": nullString(lastValidationAt),
- "lastValidationStatus": nullString(lastValidationStatus),
- "lastValidationMessage": nullString(lastValidationMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- }, nil
-}
-
-func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool {
- allowed, ok := apiKey["allowedServiceIds"].([]string)
- if !ok || len(allowed) == 0 {
- return true
- }
- for _, candidate := range allowed {
- if candidate == serviceID || candidate == slug || candidate == prefix {
- return true
- }
- }
- return false
-}
-
-func writeJSON(w http.ResponseWriter, status int, payload map[string]any) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(payload)
-}
-
-func writeError(w http.ResponseWriter, status int, code, message string) {
- writeJSON(w, status, map[string]any{
- "ok": false,
- "error": map[string]any{
- "code": code,
- "message": message,
- },
- })
-}
-
-func decodeJSON(body io.Reader, target any) error {
- decoder := json.NewDecoder(io.LimitReader(body, 1<<20))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(target); err != nil {
- return err
- }
- return nil
-}
-
-func mustJSON(value any) string {
- bytes, _ := json.Marshal(value)
- return string(bytes)
-}
-
-func boolToInt(value bool) int {
- if value {
- return 1
- }
- return 0
-}
-
-func intPtr(value int) *int {
- v := value
- return &v
-}
-
-func nullInt(value *int) any {
- if value == nil || *value <= 0 {
- return nil
- }
- return *value
-}
-
-func nullIntValue(value sql.NullInt64) *int {
- if !value.Valid {
- return nil
- }
- v := int(value.Int64)
- return &v
-}
-
-func nullIntValueFromAny(value any) *int {
- if value == nil {
- return nil
- }
- switch typed := value.(type) {
- case *int:
- return typed
- case int:
- v := typed
- return &v
- case int64:
- v := int(typed)
- return &v
- case float64:
- v := int(typed)
- return &v
- default:
- return nil
- }
-}
-
-func nullString(value sql.NullString) any {
- if !value.Valid {
- return nil
- }
- return value.String
-}
-
-func nullStringValue(value any) sql.NullString {
- if value == nil {
- return sql.NullString{}
- }
- text := fmt.Sprintf("%v", value)
- if strings.TrimSpace(text) == "" {
- return sql.NullString{}
- }
- return sql.NullString{String: text, Valid: true}
-}
-
-func nullIfEmpty(value string) any {
- v := strings.TrimSpace(value)
- if v == "" {
- return nil
- }
- return v
-}
-
-func coalesceIntPtr(value *int, fallback *int) *int {
- if value != nil {
- return value
- }
- return fallback
-}
-
-func normalizePathPrefix(value, fallback string) string {
- v := strings.TrimSpace(value)
- if v == "" {
- v = fallback
- }
- if !strings.HasPrefix(v, "/") {
- v = "/" + v
- }
- if len(v) > 1 && strings.HasSuffix(v, "/") {
- v = strings.TrimSuffix(v, "/")
- }
- return v
-}
-
-func slugify(value, fallback string) string {
- v := strings.ToLower(strings.TrimSpace(value))
- if v == "" {
- v = fallback
- }
- out := strings.Builder{}
- lastDash := false
- for _, r := range v {
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- out.WriteRune(r)
- lastDash = false
- continue
- }
- if !lastDash {
- out.WriteRune('-')
- lastDash = true
- }
- }
- result := strings.Trim(out.String(), "-")
- if result == "" {
- return fallback
- }
- return result
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
-
-func firstNonEmptyPtr(value *string, fallback string) string {
- if value != nil {
- if strings.TrimSpace(*value) == "" {
- return fallback
- }
- return strings.TrimSpace(*value)
- }
- return fallback
-}
-
-func defaultBool(value *bool, fallback bool) bool {
- if value == nil {
- return fallback
- }
- return *value
-}
-
-func asString(value any) string {
- if value == nil {
- return ""
- }
- if v, ok := value.(string); ok {
- return v
- }
- return fmt.Sprintf("%v", value)
-}
-
-func asBool(value any) bool {
- if value == nil {
- return false
- }
- switch v := value.(type) {
- case bool:
- return v
- case int:
- return v == 1
- case int64:
- return v == 1
- case float64:
- return int(v) == 1
- default:
- return false
- }
-}
-
-func intFromAny(value any, fallback int) int {
- if value == nil {
- return fallback
- }
- switch v := value.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case *int:
- if v == nil {
- return fallback
- }
- return *v
- default:
- return fallback
- }
-}
-
-func minPositive(a, b *int) *int {
- if a == nil && b == nil {
- return nil
- }
- if a == nil {
- return b
- }
- if b == nil {
- return a
- }
- if *a < *b {
- return a
- }
- return b
-}
-
-func keysOfMap(values map[string]bool) []string {
- keys := make([]string, 0, len(values))
- for key, enabled := range values {
- if enabled {
- keys = append(keys, key)
- }
- }
- sort.Strings(keys)
- return keys
-}
-
-func uniqueStrings(values []string) []string {
- seen := map[string]bool{}
- output := []string{}
- for _, value := range values {
- v := strings.TrimSpace(value)
- if v == "" || seen[v] {
- continue
- }
- seen[v] = true
- output = append(output, v)
- }
- return output
-}
-
-func normalizeProvider(value string) string {
- v := strings.ToLower(strings.TrimSpace(value))
- switch v {
- case "sqlite", "sqlite3", "file":
- return "sqlite"
- case "postgres", "postgresql", "pg":
- return "postgres"
- case "mysql", "mariadb":
- return "mysql"
- default:
- return ""
- }
-}
-
-func maskDatabaseURL(provider, raw string) string {
- if normalizeProvider(provider) == "sqlite" {
- return raw
- }
- u, err := netUrlParse(raw)
- if err != nil {
- if len(raw) <= 8 {
- return "***"
- }
- return raw[:4] + "***" + raw[len(raw)-2:]
- }
- if u.User != nil {
- username := u.User.Username()
- password, hasPassword := u.User.Password()
- if username != "" {
- if len(username) > 2 {
- username = username[:2] + "***"
- } else {
- username = "***"
- }
- }
- if hasPassword && password != "" {
- u.User = url.UserPassword(username, "***")
- } else {
- u.User = url.User(username)
- }
- }
- q := u.Query()
- for _, key := range []string{"password", "pass", "pwd", "token", "secret"} {
- if q.Has(key) {
- q.Set(key, "***")
- }
- }
- u.RawQuery = q.Encode()
- return u.String()
-}
-
-func extractDatabaseTarget(provider, raw string) string {
- if normalizeProvider(provider) == "sqlite" {
- return raw
- }
- u, err := netUrlParse(raw)
- if err != nil {
- return "-"
- }
- name := strings.TrimPrefix(u.Path, "/")
- if name == "" {
- name = "-"
- }
- return name
-}
-
-func pathAbs(value string) (string, error) {
- if strings.HasPrefix(value, "file:") {
- return strings.TrimPrefix(value, "file:"), nil
- }
- if filepath.IsAbs(value) {
- return value, nil
- }
- wd, err := os.Getwd()
- if err != nil {
- return "", err
- }
- return filepath.Join(wd, value), nil
-}
-
-func netUrlParse(value string) (*url.URL, error) {
- return url.Parse(value)
-}
-
-func webPathForRequest(requestPath string, uiBasePath string) (string, bool) {
- reqPath := path.Clean("/" + strings.TrimSpace(requestPath))
- if reqPath == "." {
- reqPath = "/"
- }
-
- base := normalizePathPrefix(uiBasePath, "/")
- if base == "/" {
- return strings.TrimPrefix(reqPath, "/"), true
- }
-
- if reqPath == base {
- return "", true
- }
-
- prefix := base + "/"
- if strings.HasPrefix(reqPath, prefix) {
- return strings.TrimPrefix(reqPath, prefix), true
- }
-
- return "", false
-}
-
-func upstreamOr(a, b sql.NullString) sql.NullString {
- if a.Valid {
- return a
- }
- return b
-}
-
-type serviceInput struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- UpstreamURL string `json:"upstreamUrl"`
- RoutePrefix string `json:"routePrefix"`
- HealthPath string `json:"healthPath"`
- UpstreamAuthHeader string `json:"upstreamAuthHeader"`
- UpstreamAuthValue string `json:"upstreamAuthValue"`
- InternalToken string `json:"internalToken"`
- Enabled *bool `json:"enabled"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- RequestTimeoutMS *int `json:"requestTimeoutMs"`
-}
-
-func (s serviceInput) RPMLimitOr(value any) *int {
- if s.RPMLimit != nil {
- return s.RPMLimit
- }
- return nullIntValueFromAny(value)
-}
-
-func (s serviceInput) MonthlyQuotaOr(value any) *int {
- if s.MonthlyQuota != nil {
- return s.MonthlyQuota
- }
- return nullIntValueFromAny(value)
-}
-
-func (s serviceInput) TimeoutOr(value any) *int {
- if s.RequestTimeoutMS != nil {
- return s.RequestTimeoutMS
- }
- return nullIntValueFromAny(value)
-}
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2
deleted file mode 100644
index 20e72b0..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff b/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff
deleted file mode 100644
index 1487d93..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff
deleted file mode 100644
index eff6edd..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2
deleted file mode 100644
index b54e556..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff
deleted file mode 100644
index 8c83fdc..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2
deleted file mode 100644
index 0804aaf..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 b/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2
deleted file mode 100644
index 732c64d..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff
deleted file mode 100644
index 338fc76..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2
deleted file mode 100644
index 21612f4..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff
deleted file mode 100644
index dfa68b0..0000000
Binary files a/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff and /dev/null differ
diff --git a/internal/apwhy/api/static/assets/index-DRUelTBf.css b/internal/apwhy/api/static/assets/index-DRUelTBf.css
deleted file mode 100644
index 63387b5..0000000
--- a/internal/apwhy/api/static/assets/index-DRUelTBf.css
+++ /dev/null
@@ -1 +0,0 @@
-@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2) format("woff2"),url(/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-greek-400-normal-CM4qok81.woff2) format("woff2"),url(/assets/manrope-greek-400-normal-DuX9RsAR.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2) format("woff2"),url(/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2) format("woff2"),url(/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-400-normal-PaqtzbVb.woff2) format("woff2"),url(/assets/manrope-latin-400-normal-8tf8FM3T.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2) format("woff2"),url(/assets/manrope-cyrillic-600-normal-It4mZcQk.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-greek-600-normal-BoRV6lzK.woff2) format("woff2"),url(/assets/manrope-greek-600-normal-CF2i9ZRY.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2) format("woff2"),url(/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2) format("woff2"),url(/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-600-normal-4f0koTD-.woff2) format("woff2"),url(/assets/manrope-latin-600-normal-BqgrALkZ.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2) format("woff2"),url(/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-greek-700-normal-CHUG9PD8.woff2) format("woff2"),url(/assets/manrope-greek-700-normal-DyfsrCpP.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2) format("woff2"),url(/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2) format("woff2"),url(/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-700-normal-BZp_XxE4.woff2) format("woff2"),url(/assets/manrope-latin-700-normal-DGRFkw-m.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2) format("woff2"),url(/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-2{height:.5rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-\[1500px\]{max-width:1500px}.max-w-\[320px\]{max-width:320px}.max-w-md{max-width:28rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.6rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-amber-500\/45{border-color:#f59e0b73}.border-amber-500\/50{border-color:#f59e0b80}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-emerald-500\/50{border-color:#10b98180}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-primary\/40{border-color:hsl(var(--primary) / .4)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-rose-500\/50{border-color:#f43f5e80}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-destructive{background-color:hsl(var(--destructive))}.bg-emerald-500\/15{background-color:#10b98126}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary\/35{background-color:hsl(var(--secondary) / .35)}.bg-secondary\/40{background-color:hsl(var(--secondary) / .4)}.bg-secondary\/60{background-color:hsl(var(--secondary) / .6)}.bg-secondary\/70{background-color:hsl(var(--secondary) / .7)}.bg-transparent{background-color:transparent}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-wide{letter-spacing:.025em}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-200{--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.text-amber-200\/90{color:#fde68ae6}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-200{--tw-text-opacity: 1;color:rgb(167 243 208 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.24\)\]{--tw-shadow: 0 8px 24px rgba(0,0,0,.24);--tw-shadow-colored: 0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--background: 147 32% 9%;--foreground: 152 28% 94%;--card: 148 30% 12%;--card-foreground: 152 28% 93%;--primary: 142 73% 45%;--primary-foreground: 144 35% 8%;--secondary: 145 32% 18%;--secondary-foreground: 145 28% 92%;--muted: 146 26% 17%;--muted-foreground: 148 18% 74%;--accent: 136 66% 40%;--accent-foreground: 150 38% 95%;--destructive: 1 74% 56%;--destructive-foreground: 0 0% 98%;--border: 146 24% 24%;--input: 147 25% 20%;--ring: 142 73% 45%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Manrope,ui-sans-serif,system-ui,sans-serif}code,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-secondary:hover{background-color:hsl(var(--secondary))}.hover\:opacity-95:hover{opacity:.95}.focus\:border-primary:focus{border-color:hsl(var(--primary))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:p-8{padding:2rem}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.xl\:grid-cols-\[420px_1fr\]{grid-template-columns:420px 1fr}}
diff --git a/internal/apwhy/api/static/assets/index-DwfYiTMH.js b/internal/apwhy/api/static/assets/index-DwfYiTMH.js
deleted file mode 100644
index 8bab5af..0000000
--- a/internal/apwhy/api/static/assets/index-DwfYiTMH.js
+++ /dev/null
@@ -1 +0,0 @@
-(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--)if(e=t[r],e.state===J)z(e);else if(e.state===fe){const n=I;I=null,H(()=>ge(e,t[0]),!1),I=n}}function H(e,t){if(I)return e();let r=!1;t||(I=[]),q?r=!0:q=[],he++;try{const n=e();return st(r),n}catch(n){r||(q=null),I=null,ke(n)}}function st(e){if(I&&(Fe(I),I=null),e)return;const t=q;q=null,t.length&&H(()=>je(t),!1)}function Fe(e){for(let t=0;t=0;t--)re(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)re(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function ue(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function ke(e,t=S){throw ue(e)}function Ce(e){if(typeof e=="function"&&!e.length)return Ce(e());if(Array.isArray(e)){const t=[];for(let r=0;r1?[]:null;return et(()=>Ie(i)),()=>{let h=e()||[],m=h.length,d,l;return h[Xe],j(()=>{let g,b,x,V,U,T,O,k,M;if(m===0)o!==0&&(Ie(i),i=[],n=[],s=[],o=0,a&&(a=[])),r.fallback&&(n=[at],s[0]=ce(G=>(i[0]=G,r.fallback())),o=1);else if(o===0){for(s=new Array(m),l=0;l=T&&k>=T&&n[O]===h[k];O--,k--)x[k]=s[O],V[k]=i[O],a&&(U[k]=a[O]);for(g=new Map,b=new Array(k+1),l=k;l>=T;l--)M=h[l],d=g.get(M),b[l]=d===void 0?-1:d,g.set(M,l);for(d=T;d<=O;d++)M=n[d],l=g.get(M),l!==void 0&&l!==-1?(x[l]=s[d],V[l]=i[d],a&&(U[l]=a[d]),l=b[l],g.set(M,l)):i[d]();for(l=T;le(t||{}))}function ae(){return!0}const ct={get(e,t,r){return t===Se?r:e.get(t)},has(e,t){return t===Se?!0:e.has(t)},set:ae,deleteProperty:ae,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:ae,deleteProperty:ae}},ownKeys(e){return e.keys()}};function we(e){return(e=typeof e=="function"?e():e)?e:{}}function ut(){for(let e=0,t=this.length;e=0;a--){const h=we(e[a])[o];if(h!==void 0)return h}},has(o){for(let a=e.length-1;a>=0;a--)if(o in we(e[a]))return!0;return!1},keys(){const o=[];for(let a=0;a=0;o--){const a=e[o];if(!a)continue;const h=Object.getOwnPropertyNames(a);for(let m=h.length-1;m>=0;m--){const d=h[m];if(d==="__proto__"||d==="constructor")continue;const l=Object.getOwnPropertyDescriptor(a,d);if(!n[d])n[d]=l.get?{enumerable:!0,configurable:!0,get:ut.bind(r[d]=[l.get.bind(a)])}:l.value!==void 0?l:void 0;else{const f=r[d];f&&(l.get?f.push(l.get.bind(a)):l.value!==void 0&&f.push(()=>l.value))}}}const s={},i=Object.keys(n);for(let o=i.length-1;o>=0;o--){const a=i[o],h=n[a];h&&h.get?Object.defineProperty(s,a,h):s[a]=h?h.value:void 0}return s}const Je=e=>`Stale read from <${e}>.`;function K(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(ot(()=>e.each,e.children,t||void 0))}function ye(e){const t=e.keyed,r=R(()=>e.when,void 0,void 0),n=t?r:R(r,void 0,{equals:(s,i)=>!s==!i});return R(()=>{const s=n();if(s){const i=e.children;return typeof i=="function"&&i.length>0?j(()=>i(t?s:()=>{if(!j(n))throw Je("Show");return r()})):i}return e.fallback},void 0,void 0)}function dt(e){const t=rt(()=>e.children),r=R(()=>{const n=t(),s=Array.isArray(n)?n:[n];let i=()=>{};for(let o=0;om()?void 0:h.when,void 0,void 0),l=h.keyed?d:R(d,void 0,{equals:(f,g)=>!f==!g});i=()=>m()||(l()?[a,d,h]:void 0)}return i});return R(()=>{const n=r()();if(!n)return e.fallback;const[s,i,o]=n,a=o.children;return typeof a=="function"&&a.length>0?j(()=>a(o.keyed?i():()=>{if(j(r)()?.[0]!==s)throw Je("Match");return i()})):a},void 0,void 0)}function W(e){return e}const ft=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],pt=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...ft]),gt=new Set(["innerHTML","textContent","innerText","children"]),ht=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),mt=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});function bt(e,t){const r=mt[e];return typeof r=="object"?r[t]?r.$:void 0:r}const yt=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Q=e=>R(()=>e());function $t(e,t,r){let n=r.length,s=t.length,i=n,o=0,a=0,h=t[s-1].nextSibling,m=null;for(;od-a){const b=t[o];for(;a{s=i,t===document?e():u(t,e(),t.firstChild?null:void 0,r)},n.owner),()=>{s(),t.textContent=""}}function p(e,t,r,n){let s;const i=()=>{const a=document.createElement("template");return a.innerHTML=e,a.content.firstChild},o=()=>(s||(s=i())).cloneNode(!0);return o.cloneNode=o,o}function Oe(e,t=window.document){const r=t[Ne]||(t[Ne]=new Set);for(let n=0,s=e.length;ns.call(e,r[1],i))}else e.addEventListener(t,r,typeof r!="function"&&r)}function _t(e,t,r={}){const n=Object.keys(t||{}),s=Object.keys(r);let i,o;for(i=0,o=s.length;is.children=ne(e,t.children,s.children)),L(()=>typeof t.ref=="function"&&Pt(t.ref,e)),L(()=>At(e,t,r,!0,s,!0)),s}function Pt(e,t,r){return j(()=>e(t,r))}function u(e,t,r,n){if(r!==void 0&&!n&&(n=[]),typeof t!="function")return ne(e,t,n,r);L(s=>ne(e,t(),s,r),n)}function At(e,t,r,n,s={},i=!1){t||(t={});for(const o in s)if(!(o in t)){if(o==="children")continue;s[o]=Re(e,o,null,s[o],r,i,t)}for(const o in t){if(o==="children")continue;const a=t[o];s[o]=Re(e,o,a,s[o],r,i,t)}}function kt(e){return e.toLowerCase().replace(/-([a-z])/g,(t,r)=>r.toUpperCase())}function Le(e,t,r){const n=t.trim().split(/\s+/);for(let s=0,i=n.length;sObject.defineProperty(e,"target",{configurable:!0,value:h}),o=()=>{const h=t[r];if(h&&!t.disabled){const m=t[`${r}Data`];if(m!==void 0?h.call(t,m,e):h.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},a=()=>{for(;o()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const h=e.composedPath();i(h[0]);for(let m=0;m{let a=t();for(;typeof a=="function";)a=a();r=ne(e,a,r,n)}),()=>r;if(Array.isArray(t)){const a=[],h=r&&Array.isArray(r);if(Ae(a,t,r,s))return L(()=>r=ne(e,a,r,n,!0)),()=>r;if(a.length===0){if(r=Y(e,r,n),o)return r}else h?r.length===0?De(e,a,n):$t(e,r,a):(r&&Y(e),De(e,a));r=a}else if(t.nodeType){if(Array.isArray(r)){if(o)return r=Y(e,r,n,t);Y(e,r,null,t)}else r==null||r===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);r=t}}return r}function Ae(e,t,r,n){let s=!1;for(let i=0,o=t.length;i=0;o--){const a=t[o];if(s!==a){const h=a.parentNode===e;!i&&!o?h?e.replaceChild(s,a):e.insertBefore(s,r):h&&a.remove()}else i=!0}}else e.insertBefore(s,r);return[s]}async function v(e,t){const r=await fetch(e,{credentials:"include",headers:{"Content-Type":"application/json",...t?.headers||{}},...t}),n=await r.json().catch(()=>({}));if(!r.ok)throw new Error(n?.error?.message||`Request failed (${r.status})`);return n}async function Tt(){return(await v("/api/v1/bootstrap/status")).data}async function Et(e,t){return v("/api/v1/bootstrap/register-owner",{method:"POST",body:JSON.stringify({email:e,password:t})})}async function It(e,t){return(await v("/api/v1/auth/login",{method:"POST",body:JSON.stringify({email:e,password:t})})).data.user}async function Ue(){return(await v("/api/v1/auth/me")).data}async function Nt(){return v("/api/v1/auth/logout",{method:"POST"})}async function Lt(e){return v("/api/v1/auth/reset-password",{method:"POST",body:JSON.stringify({newPassword:e})})}async function Rt(){return(await v("/api/v1/services")).data}async function Dt(e){return v("/api/v1/services",{method:"POST",body:JSON.stringify(e)})}async function Ut(e,t){return v(`/api/v1/services/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Mt(e){return v(`/api/v1/services/${e}/validate`,{method:"POST"})}async function jt(){return(await v("/api/v1/databases")).data}async function Vt(e){return v("/api/v1/databases",{method:"POST",body:JSON.stringify(e)})}async function Bt(e,t){return v(`/api/v1/databases/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function qt(e){return v(`/api/v1/databases/${e}/validate`,{method:"POST"})}async function Ft(){return(await v("/api/v1/keys")).data}async function Kt(e){return v("/api/v1/keys",{method:"POST",body:JSON.stringify(e)})}async function Jt(e,t){return v(`/api/v1/keys/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Ht(){return(await v("/api/v1/users")).data}async function Gt(e){return v("/api/v1/users",{method:"POST",body:JSON.stringify(e)})}async function Me(e,t){return v(`/api/v1/users/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function He(){return(await v("/api/v1/roles")).data}async function Wt(e){return v("/api/v1/roles",{method:"POST",body:JSON.stringify(e)})}async function Qt(e,t){return v(`/api/v1/roles/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Xt(){return(await v("/api/v1/permissions")).data}async function Yt(){return v("/api/v1/analytics/ops")}async function zt(){return v("/api/v1/analytics/traffic")}async function Zt(e,t,r){return v("/api/v1/analytics/events",{method:"POST",body:JSON.stringify({event:e,path:t,meta:r})})}var er=p("");function E(e){const t=()=>e.variant||"default",r=()=>t()==="outline"?"border border-border bg-transparent text-foreground hover:bg-secondary":t()==="secondary"?"border border-border bg-secondary text-secondary-foreground hover:opacity-95":t()==="danger"?"border border-destructive bg-destructive text-destructive-foreground hover:opacity-95":"border border-primary bg-primary text-primary-foreground hover:opacity-95";return(()=>{var n=er();return $e(n,be(e,{get class(){return`inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold transition ${r()} ${e.class||""}`}}),!1,!0),u(n,()=>e.children),n})()}var tr=p("");function A(e){return(()=>{var t=tr();return $e(t,be(e,{get class(){return`rounded-xl border border-border bg-card/90 p-4 shadow-[0_8px_24px_rgba(0,0,0,0.24)] ${e.class||""}`}}),!1,!0),u(t,()=>e.children),t})()}var rr=p("
");function D(e){return(()=>{var t=rr();return $e(t,be(e,{get class(){return`w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-primary ${e.class||""}`}}),!1,!1),t})()}var nr=p('
APwhy
Sign in Registration is disabled once the owner account exists.'),lr=p('