tina4-nodejs 3.13.56 → 3.13.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/core/public/js/tina4js.min.js +3 -3
- package/packages/core/src/authGate.ts +91 -0
- package/packages/core/src/router.ts +32 -1
- package/packages/core/src/server.ts +8 -50
- package/packages/core/src/testClient.ts +17 -2
- package/packages/core/src/websocket.ts +44 -3
- package/packages/core/src/websocketConnection.ts +12 -0
- package/packages/orm/src/baseModel.ts +15 -2
- package/packages/orm/src/index.ts +17 -0
- package/packages/orm/src/realtime/index.ts +13 -0
- package/packages/orm/src/realtime/models/attachment.ts +21 -0
- package/packages/orm/src/realtime/models/channel.ts +17 -0
- package/packages/orm/src/realtime/models/channelMember.ts +17 -0
- package/packages/orm/src/realtime/models/message.ts +19 -0
- package/packages/orm/src/realtime/models/workspace.ts +17 -0
- package/packages/orm/src/realtime/realtime.ts +400 -0
- package/packages/orm/src/realtime/storage.ts +189 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.58)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.58 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use strict";var Tina4=(()=>{var z=Object.defineProperty;var Ue=Object.getOwnPropertyDescriptor;var Je=Object.getOwnPropertyNames;var ze=Object.prototype.hasOwnProperty;var Ge=(e,n)=>{for(var t in n)z(e,t,{get:n[t],enumerable:!0})},Ve=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Je(n))!ze.call(e,o)&&o!==t&&z(e,o,{get:()=>n[o],enumerable:!(r=Ue(n,o))||r.enumerable});return e};var Be=e=>Ve(z({},"__esModule",{value:!0}),e);var ht={};Ge(ht,{Tina4Element:()=>A,api:()=>Te,batch:()=>U,clearPersistedKeys:()=>De,computed:()=>de,createI18n:()=>ne,effect:()=>k,html:()=>pe,i18n:()=>qe,isSignal:()=>x,navigate:()=>J,persist:()=>Pe,pwa:()=>Re,route:()=>be,router:()=>Se,signal:()=>S,sse:()=>Ce,ws:()=>_e});var M=null,D=null,P=null,K=null;function _(e){K=e}function W(){return K}var le=null,ce=null,ue=[],Ze=512;var j=0,G=new Set;function S(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(M&&(r.add(M),D)){let s=M;D.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,ce&&ce(o,i,s),j>0)for(let a of r)G.add(a);else{let a;for(let c of[...r])try{c()}catch(u){a===void 0&&(a=u)}if(a!==void 0)throw a}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return le?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},le(o,n)):ue.length<Ze&&ue.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function de(e){let n=S(void 0);return k(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function k(e){let n=!1,t=[],r=[],o=()=>{for(let a of r)a();r=[]},s=()=>{if(n)return;for(let m of t)m();t=[],o();let a=M,c=D,u=P;M=s,D=t,P=r;try{e()}finally{M=a,D=c,P=u}};s();let i=()=>{n=!0;for(let a of t)a();t=[],o()};return P&&P.push(i),K&&K.push(i),i}function U(e){j++;try{e()}finally{if(j--,j===0){let n=[...G];G.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function x(e){return e!==null&&typeof e=="object"&&e._t4===!0}var fe=new WeakMap,V="t4:";function pe(e,...n){let t=fe.get(e);if(!t){t=document.createElement("template");let i="";for(let a=0;a<e.length;a++)i+=e[a],a<n.length&&(tt(i)?i+=`__t4_${a}__`:i+=`<!--${V}${a}-->`);t.innerHTML=i,fe.set(e,t)}let r=t.content.cloneNode(!0),o=Qe(r);for(let{marker:i,index:a}of o)Ye(i,n[a]);let s=Xe(r);for(let i of s)et(i,n);return r}function Qe(e){let n=[];return Z(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(V)){let o=parseInt(r.slice(V.length),10);n.push({marker:t,index:o})}}}),n}function Xe(e){let n=[];return Z(e,t=>{t.nodeType===1&&n.push(t)}),n}function Z(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),Z(o,n)}}function Ye(e,n){let t=e.parentNode;if(t)if(x(n)){let r=document.createTextNode("");t.replaceChild(r,e),k(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];k(()=>{for(let y of s)y();s=[];let i=[],a=W();_(i);let c=n();_(a),s=i;for(let y of o)y.parentNode?.removeChild(y);o=[];let u=B(c),m=r.parentNode;if(m)for(let y of u)m.insertBefore(y,r),o.push(y)})}else if(ge(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let r=document.createDocumentFragment();for(let o of n){let s=B(o);for(let i of s)r.appendChild(i)}t.replaceChild(r,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function et(e,n){let t=[];for(let r of Array.from(e.attributes)){let o=r.name,s=r.value;if(o.startsWith("@")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];typeof u=="function"&&e.addEventListener(a,m=>U(()=>u(m)))}t.push(o);continue}if(o.startsWith("?")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];if(x(u)){let m=u;k(()=>{m.value?e.setAttribute(a,""):e.removeAttribute(a)})}else typeof u=="function"?k(()=>{u()?e.setAttribute(a,""):e.removeAttribute(a)}):u&&e.setAttribute(a,"")}t.push(o);continue}if(o.startsWith(".")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];x(u)?k(()=>{e[a]=u.value}):typeof u=="function"?k(()=>{e[a]=u()??""}):e[a]=u}t.push(o);continue}let i=s.match(/__t4_(\d+)__/);if(i){let a=n[parseInt(i[1],10)];if(x(a)){let c=a;k(()=>{e.setAttribute(o,String(c.value??""))})}else typeof a=="function"?k(()=>{e.setAttribute(o,String(a()??""))}):e.setAttribute(o,String(a??""))}}for(let r of t)e.removeAttribute(r)}function B(e){if(e==null||e===!1)return[];if(ge(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...B(t));return n}return[document.createTextNode(String(e))]}function ge(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function tt(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var me=null,he=null;var A=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=S(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=k(()=>{this._innerDisposers.splice(0).forEach(c=>c());let o=[],s=W();_(o);let i=this.render();_(s),this._innerDisposers=o;let a=Array.from(this._root.childNodes);for(let c of a)c!==r&&this._root.removeChild(c);i&&this._root.appendChild(i)}),this.onMount(),me&&me(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),he&&he(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};A.props={},A.styles="",A.shadow=!0;var X=[],N=null,F="history",nt=!1,q=[],Q=[],ve=0;function be(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?X.push({pattern:e,regex:o,paramNames:t,handler:n}):X.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function J(e,n){if(F==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),H()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),H()}function H(){if(!N)return;let e=performance.now(),n=++ve,t=F==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of X){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((c,u)=>{s[c]=decodeURIComponent(o[u+1])}),r.guard){let c=r.guard();if(c===!1)return;if(typeof c=="string"){J(c,{replace:!0});return}}Q.splice(0).forEach(c=>c()),N.innerHTML="";let i=[];_(i);let a=r.handler(s);if(a instanceof Promise)a.then(c=>{if(_(null),n!==ve){for(let m of i)m();return}ye(N,c),Q=i;let u=performance.now()-e;for(let m of q)m({path:t,params:s,pattern:r.pattern,durationMs:u})});else{_(null),ye(N,a),Q=i;let c=performance.now()-e;for(let u of q)u({path:t,params:s,pattern:r.pattern,durationMs:c})}return}}function ye(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var Se={start(e){if(N=document.querySelector(e.target),!N)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);F=e.mode??"history",nt=!0,window.addEventListener("popstate",H),F==="hash"&&window.addEventListener("hashchange",H),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=F==="hash"?t.getAttribute("href"):t.pathname;J(r)}),H()},on(e,n){return q.push(n),()=>{let t=q.indexOf(n);t>=0&&q.splice(t,1)}}};var E={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},Y=[],ee=[],rt=0;function te(){try{return localStorage.getItem(E.tokenKey)}catch{return null}}function ot(e){try{localStorage.setItem(E.tokenKey,e)}catch{}}function we(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function ke(e,n){e._url=n,e._requestId=++rt;for(let a of Y){let c=a(e);c&&(e=c)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&ot(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let a of ee){let c=a(i);c&&(i=c)}if(!t.ok)throw i;return i.data}async function L(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",...E.headers}};if(E.auth){let s=te();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(E.auth&&typeof s=="object"&&s!==null){let i=te();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=we(n,r.params)),ke(o,E.baseUrl+n)}var Te={configure(e){Object.assign(E,e)},get(e,n){return L("GET",e,void 0,n)},post(e,n,t){return L("POST",e,n,t)},put(e,n,t){return L("PUT",e,n,t)},patch(e,n,t){return L("PATCH",e,n,t)},delete(e,n){return L("DELETE",e,void 0,n)},async graphql(e,n,t,r){return L("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{...E.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],E.auth){let o=te();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=we(e,t.params)),ke(r,E.baseUrl+e)},intercept(e,n){e==="request"?Y.push(n):ee.push(n)},_reset(){E.baseUrl="",E.auth=!1,E.tokenKey="tina4_token",E.headers={},Y.length=0,ee.length=0}};function st(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
1
|
+
"use strict";var Tina4=(()=>{var X=Object.defineProperty;var Be=Object.getOwnPropertyDescriptor;var Ve=Object.getOwnPropertyNames;var Ge=Object.prototype.hasOwnProperty;var Ze=(e,n)=>{for(var t in n)X(e,t,{get:n[t],enumerable:!0})},Qe=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Ve(n))!Ge.call(e,o)&&o!==t&&X(e,o,{get:()=>n[o],enumerable:!(r=Be(n,o))||r.enumerable});return e};var Xe=e=>Qe(X({},"__esModule",{value:!0}),e);var Et={};Ze(Et,{Tina4Element:()=>P,api:()=>_e,batch:()=>V,clearPersistedKeys:()=>je,computed:()=>me,createI18n:()=>le,effect:()=>R,html:()=>ve,i18n:()=>We,isSignal:()=>I,navigate:()=>G,persist:()=>Ue,pwa:()=>Me,route:()=>Te,router:()=>Ce,rtc:()=>Ie,rtcConfig:()=>Z,signal:()=>k,sse:()=>Oe,ws:()=>W});var L=null,q=null,$=null,J=null;function O(e){J=e}function B(){return J}var fe=null,ge=null,pe=[],Ye=512;var z=0,Y=new Set;function k(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(L&&(r.add(L),q)){let s=L;q.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,ge&&ge(o,i,s),z>0)for(let l of r)Y.add(l);else{let l;for(let d of[...r])try{d()}catch(f){l===void 0&&(l=f)}if(l!==void 0)throw l}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return fe?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},fe(o,n)):pe.length<Ye&&pe.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function me(e){let n=k(void 0);return R(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function R(e){let n=!1,t=[],r=[],o=()=>{for(let l of r)l();r=[]},s=()=>{if(n)return;for(let h of t)h();t=[],o();let l=L,d=q,f=$;L=s,q=t,$=r;try{e()}finally{L=l,q=d,$=f}};s();let i=()=>{n=!0;for(let l of t)l();t=[],o()};return $&&$.push(i),J&&J.push(i),i}function V(e){z++;try{e()}finally{if(z--,z===0){let n=[...Y];Y.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function I(e){return e!==null&&typeof e=="object"&&e._t4===!0}var he=new WeakMap,ee="t4:";function ve(e,...n){let t=he.get(e);if(!t){t=document.createElement("template");let i="";for(let l=0;l<e.length;l++)i+=e[l],l<n.length&&(ot(i)?i+=`__t4_${l}__`:i+=`<!--${ee}${l}-->`);t.innerHTML=i,he.set(e,t)}let r=t.content.cloneNode(!0),o=et(r);for(let{marker:i,index:l}of o)nt(i,n[l]);let s=tt(r);for(let i of s)rt(i,n);return r}function et(e){let n=[];return ne(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(ee)){let o=parseInt(r.slice(ee.length),10);n.push({marker:t,index:o})}}}),n}function tt(e){let n=[];return ne(e,t=>{t.nodeType===1&&n.push(t)}),n}function ne(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),ne(o,n)}}function nt(e,n){let t=e.parentNode;if(t)if(I(n)){let r=document.createTextNode("");t.replaceChild(r,e),R(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];R(()=>{for(let w of s)w();s=[];let i=[],l=B();O(i);let d=n();O(l),s=i;for(let w of o)w.parentNode?.removeChild(w);o=[];let f=te(d),h=r.parentNode;if(h)for(let w of f)h.insertBefore(w,r),o.push(w)})}else if(ye(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let r=document.createDocumentFragment();for(let o of n){let s=te(o);for(let i of s)r.appendChild(i)}t.replaceChild(r,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function rt(e,n){let t=[];for(let r of Array.from(e.attributes)){let o=r.name,s=r.value;if(o.startsWith("@")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];typeof f=="function"&&e.addEventListener(l,h=>V(()=>f(h)))}t.push(o);continue}if(o.startsWith("?")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];if(I(f)){let h=f;R(()=>{h.value?e.setAttribute(l,""):e.removeAttribute(l)})}else typeof f=="function"?R(()=>{f()?e.setAttribute(l,""):e.removeAttribute(l)}):f&&e.setAttribute(l,"")}t.push(o);continue}if(o.startsWith(".")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];I(f)?R(()=>{e[l]=f.value}):typeof f=="function"?R(()=>{e[l]=f()??""}):e[l]=f}t.push(o);continue}let i=s.match(/__t4_(\d+)__/);if(i){let l=n[parseInt(i[1],10)];if(I(l)){let d=l;R(()=>{e.setAttribute(o,String(d.value??""))})}else typeof l=="function"?R(()=>{e.setAttribute(o,String(l()??""))}):e.setAttribute(o,String(l??""))}}for(let r of t)e.removeAttribute(r)}function te(e){if(e==null||e===!1)return[];if(ye(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...te(t));return n}return[document.createTextNode(String(e))]}function ye(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function ot(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var be=null,Se=null;var P=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=k(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=R(()=>{this._innerDisposers.splice(0).forEach(d=>d());let o=[],s=B();O(o);let i=this.render();O(s),this._innerDisposers=o;let l=Array.from(this._root.childNodes);for(let d of l)d!==r&&this._root.removeChild(d);i&&this._root.appendChild(i)}),this.onMount(),be&&be(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),Se&&Se(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};P.props={},P.styles="",P.shadow=!0;var oe=[],D=null,U="history",st=!1,j=[],re=[],ke=0;function Te(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?oe.push({pattern:e,regex:o,paramNames:t,handler:n}):oe.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function G(e,n){if(U==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),H()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),H()}function H(){if(!D)return;let e=performance.now(),n=++ke,t=U==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of oe){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((d,f)=>{s[d]=decodeURIComponent(o[f+1])}),r.guard){let d=r.guard();if(d===!1)return;if(typeof d=="string"){G(d,{replace:!0});return}}re.splice(0).forEach(d=>d()),D.innerHTML="";let i=[];O(i);let l=r.handler(s);if(l instanceof Promise)l.then(d=>{if(O(null),n!==ke){for(let h of i)h();return}we(D,d),re=i;let f=performance.now()-e;for(let h of j)h({path:t,params:s,pattern:r.pattern,durationMs:f})});else{O(null),we(D,l),re=i;let d=performance.now()-e;for(let f of j)f({path:t,params:s,pattern:r.pattern,durationMs:d})}return}}function we(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var Ce={start(e){if(D=document.querySelector(e.target),!D)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);U=e.mode??"history",st=!0,window.addEventListener("popstate",H),U==="hash"&&window.addEventListener("hashchange",H),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=U==="hash"?t.getAttribute("href"):t.pathname;G(r)}),H()},on(e,n){return j.push(n),()=>{let t=j.indexOf(n);t>=0&&j.splice(t,1)}}};var x={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},se=[],ie=[],it=0;function ae(){try{return localStorage.getItem(x.tokenKey)}catch{return null}}function at(e){try{localStorage.setItem(x.tokenKey,e)}catch{}}function Ee(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function Re(e,n){e._url=n,e._requestId=++it;for(let l of se){let d=l(e);d&&(e=d)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&at(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let l of ie){let d=l(i);d&&(i=d)}if(!t.ok)throw i;return i.data}async function F(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",...x.headers}};if(x.auth){let s=ae();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(x.auth&&typeof s=="object"&&s!==null){let i=ae();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=Ee(n,r.params)),Re(o,x.baseUrl+n)}var _e={configure(e){Object.assign(x,e)},get(e,n){return F("GET",e,void 0,n)},post(e,n,t){return F("POST",e,n,t)},put(e,n,t){return F("PUT",e,n,t)},patch(e,n,t){return F("PATCH",e,n,t)},delete(e,n){return F("DELETE",e,void 0,n)},async graphql(e,n,t,r){return F("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{...x.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],x.auth){let o=ae();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=Ee(e,t.params)),Re(r,x.baseUrl+e)},intercept(e,n){e==="request"?se.push(n):ie.push(n)},_reset(){x.baseUrl="",x.auth=!1,x.tokenKey="tina4_token",x.headers={},se.length=0,ie.length=0}};function lt(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
2
2
|
const CACHE = 'tina4-v1';
|
|
3
3
|
const PRECACHE = ${t};
|
|
4
4
|
const OFFLINE = ${r};
|
|
@@ -44,5 +44,5 @@ self.addEventListener('fetch', (e) => {
|
|
|
44
44
|
))
|
|
45
45
|
);`}
|
|
46
46
|
});
|
|
47
|
-
`.trim()}function
|
|
48
|
-
`);$=ie.pop();for(let We of ie){let ae=We.trim();ae&&T(v(ae),null)}}let se=$.trim();se&&T(v(se),null),y=null,b()}).catch(w=>{w.name!=="AbortError"&&(y=null,R(w),b())})}function $e(){d++,c.value=d,r.value="reconnecting",p=setTimeout(()=>{p=null,re()},l),l=Math.min(l*2,t.reconnectMaxDelay)}function re(){t.mode==="fetch"?He():I()}let oe={status:r,connected:o,lastMessage:s,lastEvent:i,error:a,reconnectCount:c,on(h,w){return u[h].push(w),()=>{let C=u[h],O=C.indexOf(w);O>=0&&C.splice(O,1)}},pipe(h,w){let C=O=>{h.value=w(O,h.value)};return oe.on("message",C)},close(){f=!0,p&&(clearTimeout(p),p=null),m&&(m.close(),m=null),y&&(y.abort(),y=null),r.value="closed",o.value=!1}};return re(),oe}var Ce={connect:ut};var xe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},Ae=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,dt=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,ft=/^[A-Za-z0-9+/_=-]{40,}$/,Ie=new Set;function Oe(e,n){if(Ae.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(dt.test(n))return"value looks like a JWT";if(n.length>=40&&ft.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if(Ae.test(t))return`object contains a credential-shape field "${t}"`}return null}function Me(e,n){Ie.has(n)||(Ie.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function Le(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function Pe(e,n){let{key:t,storage:r="local",serializer:o=xe,version:s=1,migrate:i,syncTabs:a=!1,silenceCredentialWarning:c=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let u=o===xe,m=Le(r);if(!m)return Ne(e,()=>{},()=>{});try{let l=m.getItem(t);if(l!==null){let p,d;try{let v=JSON.parse(l);v&&typeof v=="object"&&"value"in v?(p=v.v,d=v.value):d=v}catch{d=u?l:o.read(l)}if(p===s||p===void 0){let v=u?d:o.read(typeof d=="string"?d:JSON.stringify(d));e.value=v}else if(i)try{e.value=i(d,p)}catch(v){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,v)}else console.warn(`[tina4 persist] stored version ${p} does not match current ${s} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}}catch(l){console.warn(`[tina4 persist] failed to read key "${t}":`,l)}if(!c){let l=Oe(t,e.peek());l&&Me(l,t)}let y=k(()=>{let l=e.value;if(!c){let p=Oe(t,l);p&&Me(p,t)}try{let d=JSON.stringify(u?{v:s,value:l}:{v:s,value:o.write(l)});m.setItem(t,d)}catch(p){console.warn(`[tina4 persist] failed to write key "${t}":`,p)}}),f=null;if(a&&typeof globalThis<"u"&&"addEventListener"in globalThis){let l=p=>{let d=p;if(d.storageArea===m&&d.key===t&&d.newValue!==null)try{let v=JSON.parse(d.newValue),T=v&&typeof v=="object"&&"v"in v?v.v:void 0,g=T!==void 0?v.value:v;T!==void 0&&T!==s&&i?e.value=i(g,T):(T===s||T===void 0)&&(e.value=u?g:o.read(typeof g=="string"?g:JSON.stringify(g)))}catch(v){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,v)}};globalThis.addEventListener?.("storage",l),f=()=>{globalThis.removeEventListener?.("storage",l)}}return Ne(e,()=>{try{m.removeItem(t)}catch(l){console.warn(`[tina4 persist] failed to clear key "${t}":`,l)}},()=>{y(),f&&f()})}function Ne(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function De(e,n="local"){let t=Le(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var pt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function gt(){return globalThis.navigator?.language||"en"}function Fe(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))Fe(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function mt(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function ne(e={}){let n=e.locale||gt(),t=e.fallbackLocale||n,r=new Set([...pt,...e.rtlLocales||[]]),o=S(n,"i18n.locale"),s=new Map,i=new Map;function a(f,l){let p=Fe(l),d=s.get(f);s.set(f,d?{...d,...p}:p)}if(e.messages)for(let[f,l]of Object.entries(e.messages))a(f,l);function c(f,l){return s.get(f)?.[l]}function u(f,l){let p=`n|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.NumberFormat(f,l),i.set(p,d)),d}function m(f,l){let p=`d|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.DateTimeFormat(f,l),i.set(p,d)),d}function y(f,l){let p=`r|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.RelativeTimeFormat(f,l),i.set(p,d)),d}return{locale:o,t(f,l){let p=o.value,d=c(p,f);return d===void 0&&t!==p&&(d=c(t,f)),d===void 0&&(d=f),l?mt(d,l):d},setLocale(f){o.value=f},getLocale(){return o.value},addMessages:a,hasLocale(f){return s.has(f)},availableLocales(){return[...s.keys()].sort()},async loadMessages(f,l){let p=await fetch(l);if(!p.ok)throw new Error(`[tina4 i18n] failed to load "${f}" from ${l}: ${p.status}`);a(f,await p.json())},number(f,l){return u(o.value,l).format(f)},currency(f,l,p){return u(o.value,{style:"currency",currency:l,...p}).format(f)},date(f,l){let p=f instanceof Date?f:new Date(f);return m(o.value,l).format(p)},relativeTime(f,l,p){return y(o.value,p||{numeric:"auto"}).format(f,l)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var qe=ne();return Be(ht);})();
|
|
47
|
+
`.trim()}function xe(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var Me={register(e){let n=xe(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),r=document.createElement("link");r.rel="manifest",r.href=URL.createObjectURL(t),document.head.appendChild(r);let o=document.querySelector('meta[name="theme-color"]');o||(o=document.createElement("meta"),o.name="theme-color",document.head.appendChild(o)),o.content=e.themeColor??"#000000","serviceWorker"in navigator&&(e.swUrl?navigator.serviceWorker.register(e.swUrl).catch(s=>{console.warn("[tina4] Service worker registration failed:",s)}):navigator.serviceWorker.register("/sw.js").catch(()=>{console.info("[tina4] No service worker at /sw.js. Use pwa.generateServiceWorker() to create one, or pass swUrl in config.")}))},generateServiceWorker(e){return lt(e)},generateManifest(e){return xe(e)}};var ct={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function ut(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function dt(e,n={}){let t={...ct,...n},r=k("connecting"),o=k(!1),s=k(null),i=k(null),l=k(0),d={message:[],open:[],close:[],error:[]},f=null,h=!1,w=t.reconnectDelay,u=null,a=0;function c(m){if(typeof m!="string")return m;try{return JSON.parse(m)}catch{return m}}function g(){r.value=a>0?"reconnecting":"connecting";try{f=new WebSocket(e,ut(t))}catch{r.value="closed",o.value=!1;return}f.onopen=()=>{r.value="open",o.value=!0,i.value=null,a=0,w=t.reconnectDelay,l.value=0;for(let m of d.open)m()},f.onmessage=m=>{let T=c(m.data);s.value=T;for(let _ of d.message)_(T)},f.onclose=m=>{r.value="closed",o.value=!1;for(let T of d.close)T(m.code,m.reason);!h&&t.reconnect&&a<t.reconnectAttempts&&y()},f.onerror=m=>{i.value=m;for(let T of d.error)T(m)}}function y(){a++,l.value=a,r.value="reconnecting",u=setTimeout(()=>{u=null,g()},w),w=Math.min(w*2,t.reconnectMaxDelay)}let C={status:r,connected:o,lastMessage:s,error:i,reconnectCount:l,send(m){if(!f||f.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let T=typeof m=="string"?m:JSON.stringify(m);f.send(T)},on(m,T){return d[m].push(T),()=>{let _=d[m],A=_.indexOf(T);A>=0&&_.splice(A,1)}},pipe(m,T){let _=A=>{m.value=T(A,m.value)};return C.on("message",_)},close(m,T){h=!0,u&&(clearTimeout(u),u=null),f&&f.close(m??1e3,T??""),r.value="closed",o.value=!1}};return g(),C}var W={connect:dt};var ft={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function gt(e,n={}){let t={...ft,...n},r=k("connecting"),o=k(!1),s=k(null),i=k(null),l=k(null),d=k(0),f={message:[],open:[],close:[],error:[]},h=null,w=null,u=!1,a=t.reconnectDelay,c=null,g=0;function y(p){if(!t.json||typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function C(p,b){s.value=p,i.value=b;for(let E of f.message)E(p,b??void 0)}function m(){r.value="open",o.value=!0,l.value=null,g=0,a=t.reconnectDelay,d.value=0;for(let p of f.open)p()}function T(){r.value="closed",o.value=!1;for(let p of f.close)p();!u&&t.reconnect&&g<t.reconnectAttempts&&Q()}function _(p){l.value=p;for(let b of f.error)b(p)}function A(){r.value=g>0?"reconnecting":"connecting";try{h=new EventSource(e)}catch{r.value="closed",o.value=!1;return}h.onopen=()=>m(),h.onmessage=p=>{C(y(p.data),null)};for(let p of t.events)h.addEventListener(p,b=>{C(y(b.data),p)});h.onerror=p=>{_(p),h&&h.readyState===2&&(h=null,T())}}function K(){r.value=g>0?"reconnecting":"connecting",w=new AbortController;let p={method:t.method,headers:t.headers,signal:w.signal};t.body!==void 0&&(p.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,p).then(async b=>{if(!b.ok){_(new Error(`[tina4] SSE fetch ${b.status}`)),T();return}m();let E=b.body.getReader(),M=new TextDecoder,N="";for(;;){let{done:Ke,value:ze}=await E.read();if(Ke)break;N+=M.decode(ze,{stream:!0});let ue=N.split(`
|
|
48
|
+
`);N=ue.pop();for(let Je of ue){let de=Je.trim();de&&C(y(de),null)}}let ce=N.trim();ce&&C(y(ce),null),w=null,T()}).catch(b=>{b.name!=="AbortError"&&(w=null,_(b),T())})}function Q(){g++,d.value=g,r.value="reconnecting",c=setTimeout(()=>{c=null,S()},a),a=Math.min(a*2,t.reconnectMaxDelay)}function S(){t.mode==="fetch"?K():A()}let v={status:r,connected:o,lastMessage:s,lastEvent:i,error:l,reconnectCount:d,on(p,b){return f[p].push(b),()=>{let E=f[p],M=E.indexOf(b);M>=0&&E.splice(M,1)}},pipe(p,b){let E=M=>{p.value=b(M,p.value)};return v.on("message",E)},close(){u=!0,c&&(clearTimeout(c),c=null),h&&(h.close(),h=null),w&&(w.abort(),w=null),r.value="closed",o.value=!1}};return S(),v}var Oe={connect:gt};async function Z(e="/api/rtc/config"){let n=await fetch(e);if(!n.ok)throw new Error(`[tina4] rtc config fetch failed: ${n.status}`);return n.json()}function pt(){let e=window.location;return`${e.protocol==="https:"?"wss:":"ws:"}//${e.host}`}function Ae(e){return/^wss?:\/\//.test(e)?e:pt()+(e.startsWith("/")?e:"/"+e)}function mt(){let e=globalThis.crypto;if(e&&"randomUUID"in e)return e.randomUUID().slice(0,8);let n="";for(let t=0;t<8;t++)n+=Math.floor(16*(.5+t)).toString(16);return n+Date.now().toString(16).slice(-4)}async function ht(e,n={}){let t=k("connecting"),r=k(null),o=k([]),s=k(!1),i=k(null),l=mt(),d=n.config??await Z(n.configUrl),f=n.iceServers??d.iceServers??[],h=n.signallingUrl??d.signalling??"/ws/rtc",w=Ae(h.includes("{room}")?h.replace("{room}",e):`${h}/${e}`),u=null;n.media instanceof MediaStream?u=n.media:n.media!==!1&&(u=await navigator.mediaDevices.getUserMedia(n.media??{audio:!0,video:!0})),r.value=u;let a=u?.getVideoTracks()[0]??null,c=new Map,g=W.connect(w);function y(){o.value=[...c.entries()].map(([S,v])=>({id:S,stream:v.stream}))}function C(S){try{g.send({...S,from:l})}catch{}}function m(S){let v=c.get(S);if(v)return v;let p=new RTCPeerConnection({iceServers:f}),b={pc:p,polite:l<S,makingOffer:!1,ignoreOffer:!1,stream:null};if(c.set(S,b),u)for(let E of u.getTracks())p.addTrack(E,u);return p.onnegotiationneeded=async()=>{try{b.makingOffer=!0,await p.setLocalDescription(),C({type:"desc",to:S,description:p.localDescription})}catch(E){i.value=E}finally{b.makingOffer=!1}},p.onicecandidate=({candidate:E})=>{E&&C({type:"ice",to:S,candidate:E})},p.ontrack=({streams:E})=>{b.stream=E[0]??null,y()},p.onconnectionstatechange=()=>{["failed","closed"].includes(p.connectionState)?T(S):p.connectionState==="connected"&&(t.value="connected")},y(),b}function T(S){let v=c.get(S);if(v){try{v.pc.close()}catch{}c.delete(S),y()}}async function _(S){let v=S,p=v.from;if(!p||p===l||v.to&&v.to!==l)return;if(v.type==="hello"){m(p),C({type:"welcome",to:p});return}if(v.type==="welcome"){m(p);return}if(v.type==="bye"){T(p);return}let b=m(p),E=b.pc;if(v.type==="desc"){let M=v.description,N=M.type==="offer"&&(b.makingOffer||E.signalingState!=="stable");if(b.ignoreOffer=!b.polite&&N,b.ignoreOffer)return;await E.setRemoteDescription(M),M.type==="offer"&&(await E.setLocalDescription(),C({type:"desc",to:p,description:E.localDescription}))}else if(v.type==="ice")try{await E.addIceCandidate(v.candidate)}catch(M){b.ignoreOffer||(i.value=M)}}g.on("message",S=>{_(S)}),g.on("open",()=>{C({type:"hello"})});async function A(S){if(S)for(let{pc:v}of c.values()){let p=v.getSenders().find(b=>b.track?.kind==="video");p&&await p.replaceTrack(S)}}async function K(){await A(a),s.value=!1}async function Q(){let v=(await navigator.mediaDevices.getDisplayMedia({video:!0})).getVideoTracks()[0];await A(v),v.onended=()=>{K()},s.value=!0}return{status:t,localStream:r,peers:o,screenSharing:s,error:i,id:l,shareScreen:Q,stopScreen:K,toggleAudio(S){let v=u?.getAudioTracks()[0];return v?(v.enabled=S??!v.enabled,v.enabled):!1},toggleVideo(S){let v=u?.getVideoTracks()[0];return v?(v.enabled=S??!v.enabled,v.enabled):!1},leave(){C({type:"bye"});for(let S of[...c.keys()])T(S);if(u)for(let S of u.getTracks())S.stop();g.close(),t.value="closed"}}}function vt(e,n={}){let t=k([]),r=k([]),o=k([]),s=new Map,i=n.typingTimeout??3e3,l=n.url??"/ws/chat",d=Ae(l.includes("{channel}")?l.replace("{channel}",String(e)):`${l}/${e}`),f=W.connect(d,{token:n.token});function h(a){o.value.includes(a)||(o.value=[...o.value,a]);let c=s.get(a);c&&clearTimeout(c),s.set(a,setTimeout(()=>{o.value=o.value.filter(g=>g!==a),s.delete(a)},i))}f.on("message",a=>{let c=a;switch(c.type){case"message":t.value=[...t.value,c.message];break;case"presence":c.event==="roster"?r.value=c.users??[]:c.event==="join"&&c.user_id?r.value=[...new Set([...r.value,c.user_id])]:c.event==="leave"&&(r.value=r.value.filter(g=>g!==c.user_id));break;case"typing":c.user_id&&h(c.user_id);break}});let w=n.apiBase??"",u=n.messagesPath??"/api/channels/{id}/messages";return{status:f.status,connected:f.connected,messages:t,presence:r,typing:o,send(a,c){f.send({type:"message",body:a,thread_id:c??null})},sendTyping(){f.send({type:"typing"})},markRead(){f.send({type:"read"})},async history(a,c=50){let g=u.replace("{id}",String(e)),y=new URLSearchParams({limit:String(c)});a&&y.set("before",String(a));let C={};n.token&&(C.Authorization=`Bearer ${n.token}`);let m=await fetch(`${w}${g}?${y}`,{headers:C});if(!m.ok)throw new Error(`[tina4] chat history failed: ${m.status}`);let T=await m.json(),_=[...T].reverse();return t.value=[..._,...t.value],T},close(){for(let a of s.values())clearTimeout(a);s.clear(),f.close()}}}async function yt(e,n,t={}){let r=t.filesPath??"/api/files",o=new FormData;o.append("channel_id",String(e)),o.append("file",n,n.name??"file");let s={};t.token&&(s.Authorization=`Bearer ${t.token}`);let i=await fetch(`${t.apiBase??""}${r}`,{method:"POST",body:o,headers:s});if(!i.ok)throw new Error(`[tina4] file upload failed: ${i.status}`);return i.json()}async function bt(e,n={}){let t=/^https?:\/\//.test(e)?e:`${n.apiBase??""}${n.filesPath??"/api/files"}/${e}`,r={};n.token&&(r.Authorization=`Bearer ${n.token}`);let o=await fetch(t,{headers:r});if(!o.ok)throw new Error(`[tina4] file fetch failed: ${o.status}`);return URL.createObjectURL(await o.blob())}var Ie={config:Z,call:ht,chat:vt,upload:yt,fetchBlob:bt};var Pe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},Ne=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,St=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,kt=/^[A-Za-z0-9+/_=-]{40,}$/,Le=new Set;function De(e,n){if(Ne.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(St.test(n))return"value looks like a JWT";if(n.length>=40&&kt.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if(Ne.test(t))return`object contains a credential-shape field "${t}"`}return null}function Fe(e,n){Le.has(n)||(Le.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function qe(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function Ue(e,n){let{key:t,storage:r="local",serializer:o=Pe,version:s=1,migrate:i,syncTabs:l=!1,silenceCredentialWarning:d=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let f=o===Pe,h=qe(r);if(!h)return $e(e,()=>{},()=>{});try{let a=h.getItem(t);if(a!==null){let c,g;try{let y=JSON.parse(a);y&&typeof y=="object"&&"value"in y?(c=y.v,g=y.value):g=y}catch{g=f?a:o.read(a)}if(c===s||c===void 0){let y=f?g:o.read(typeof g=="string"?g:JSON.stringify(g));e.value=y}else if(i)try{e.value=i(g,c)}catch(y){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,y)}else console.warn(`[tina4 persist] stored version ${c} does not match current ${s} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}}catch(a){console.warn(`[tina4 persist] failed to read key "${t}":`,a)}if(!d){let a=De(t,e.peek());a&&Fe(a,t)}let w=R(()=>{let a=e.value;if(!d){let c=De(t,a);c&&Fe(c,t)}try{let g=JSON.stringify(f?{v:s,value:a}:{v:s,value:o.write(a)});h.setItem(t,g)}catch(c){console.warn(`[tina4 persist] failed to write key "${t}":`,c)}}),u=null;if(l&&typeof globalThis<"u"&&"addEventListener"in globalThis){let a=c=>{let g=c;if(g.storageArea===h&&g.key===t&&g.newValue!==null)try{let y=JSON.parse(g.newValue),C=y&&typeof y=="object"&&"v"in y?y.v:void 0,m=C!==void 0?y.value:y;C!==void 0&&C!==s&&i?e.value=i(m,C):(C===s||C===void 0)&&(e.value=f?m:o.read(typeof m=="string"?m:JSON.stringify(m)))}catch(y){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,y)}};globalThis.addEventListener?.("storage",a),u=()=>{globalThis.removeEventListener?.("storage",a)}}return $e(e,()=>{try{h.removeItem(t)}catch(a){console.warn(`[tina4 persist] failed to clear key "${t}":`,a)}},()=>{w(),u&&u()})}function $e(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function je(e,n="local"){let t=qe(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var wt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function Tt(){return globalThis.navigator?.language||"en"}function He(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))He(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function Ct(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function le(e={}){let n=e.locale||Tt(),t=e.fallbackLocale||n,r=new Set([...wt,...e.rtlLocales||[]]),o=k(n,"i18n.locale"),s=new Map,i=new Map;function l(u,a){let c=He(a),g=s.get(u);s.set(u,g?{...g,...c}:c)}if(e.messages)for(let[u,a]of Object.entries(e.messages))l(u,a);function d(u,a){return s.get(u)?.[a]}function f(u,a){let c=`n|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.NumberFormat(u,a),i.set(c,g)),g}function h(u,a){let c=`d|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.DateTimeFormat(u,a),i.set(c,g)),g}function w(u,a){let c=`r|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.RelativeTimeFormat(u,a),i.set(c,g)),g}return{locale:o,t(u,a){let c=o.value,g=d(c,u);return g===void 0&&t!==c&&(g=d(t,u)),g===void 0&&(g=u),a?Ct(g,a):g},setLocale(u){o.value=u},getLocale(){return o.value},addMessages:l,hasLocale(u){return s.has(u)},availableLocales(){return[...s.keys()].sort()},async loadMessages(u,a){let c=await fetch(a);if(!c.ok)throw new Error(`[tina4 i18n] failed to load "${u}" from ${a}: ${c.status}`);l(u,await c.json())},number(u,a){return f(o.value,a).format(u)},currency(u,a,c){return f(o.value,{style:"currency",currency:a,...c}).format(u)},date(u,a){let c=u instanceof Date?u:new Date(u);return h(o.value,a).format(c)},relativeTime(u,a,c){return w(o.value,c||{numeric:"auto"}).format(u,a)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var We=le();return Xe(Et);})();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure-by-default route auth gate — shared by the live server dispatch and the
|
|
3
|
+
* in-process TestClient so the test surface enforces the IDENTICAL gate as
|
|
4
|
+
* production.
|
|
5
|
+
*
|
|
6
|
+
* The TestClient used to match a route and run its handler directly, skipping
|
|
7
|
+
* this check, so a tokenless write returned the handler's 201 in a test while
|
|
8
|
+
* the live server returned 401 — a green test hid a live failure and the
|
|
9
|
+
* verification layer lied. This is the same defect fixed in tina4-python under
|
|
10
|
+
* #PY2; extracting the gate here lets both callers share one implementation.
|
|
11
|
+
*/
|
|
12
|
+
import { validToken, getPayload, refreshToken } from "./auth.js";
|
|
13
|
+
import type { Tina4Request, Tina4Response } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Just the auth-relevant fields of a matched route. */
|
|
16
|
+
export interface AuthGateRoute {
|
|
17
|
+
secure?: boolean;
|
|
18
|
+
noAuth?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Enforce auth for a matched route.
|
|
23
|
+
*
|
|
24
|
+
* Returns `true` when the request is REJECTED — a 401 has already been written
|
|
25
|
+
* to `res.raw` and the caller must stop (not run the handler). Returns `false`
|
|
26
|
+
* when the route is public OR a valid token was presented (in which case
|
|
27
|
+
* `req.user` is populated and, for a body formToken, a `FreshToken` header is
|
|
28
|
+
* set). Auth is enforced only for a route that is `secure` and not `noAuth`,
|
|
29
|
+
* and never for `/__dev` dev-admin routes.
|
|
30
|
+
*
|
|
31
|
+
* Token sources, in priority order: `Authorization: Bearer` header, a
|
|
32
|
+
* `formToken` in the parsed body, then a session token.
|
|
33
|
+
*/
|
|
34
|
+
export function enforceRouteAuth(
|
|
35
|
+
req: Tina4Request,
|
|
36
|
+
res: Tina4Response,
|
|
37
|
+
match: AuthGateRoute,
|
|
38
|
+
isDevAdmin: boolean,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!(match.secure === true && match.noAuth !== true && !isDevAdmin)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const authHeader = req.headers.authorization ?? "";
|
|
45
|
+
const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
46
|
+
|
|
47
|
+
let resolvedToken = "";
|
|
48
|
+
let tokenSource: "header" | "body" | "session" | "" = "";
|
|
49
|
+
|
|
50
|
+
// Priority 1: Authorization Bearer header
|
|
51
|
+
if (headerToken && validToken(headerToken)) {
|
|
52
|
+
resolvedToken = headerToken;
|
|
53
|
+
tokenSource = "header";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Priority 2: formToken from request body
|
|
57
|
+
if (!resolvedToken) {
|
|
58
|
+
const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
|
|
59
|
+
if (bodyToken && validToken(bodyToken)) {
|
|
60
|
+
resolvedToken = bodyToken;
|
|
61
|
+
tokenSource = "body";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Priority 3: Session token
|
|
66
|
+
if (!resolvedToken) {
|
|
67
|
+
const sessionToken = (req as any).session?.get?.("token") as string | undefined;
|
|
68
|
+
if (sessionToken && validToken(sessionToken)) {
|
|
69
|
+
resolvedToken = sessionToken;
|
|
70
|
+
tokenSource = "session";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!resolvedToken) {
|
|
75
|
+
res.raw.writeHead(401, { "Content-Type": "application/json" });
|
|
76
|
+
res.raw.end(JSON.stringify({ error: "Unauthorized" }));
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
req.user = getPayload(resolvedToken) ?? {};
|
|
81
|
+
|
|
82
|
+
// When a body formToken validates, return a FreshToken header with a refreshed JWT
|
|
83
|
+
if (tokenSource === "body") {
|
|
84
|
+
const fresh = refreshToken(resolvedToken);
|
|
85
|
+
if (fresh) {
|
|
86
|
+
res.header("FreshToken", fresh);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
@@ -463,10 +463,35 @@ export class Router {
|
|
|
463
463
|
|
|
464
464
|
/**
|
|
465
465
|
* Match a WebSocket upgrade request path to a registered ws route.
|
|
466
|
+
* Returns the route only; use {@link matchWebSocketWithParams} when the
|
|
467
|
+
* upgrade handler needs the extracted `{param}` values.
|
|
466
468
|
*/
|
|
467
469
|
matchWebSocket(pathname: string): WebSocketRouteDefinition | null {
|
|
470
|
+
return this.matchWebSocketWithParams(pathname)?.route ?? null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Match a WebSocket upgrade path AND extract its `{param}` values, using the
|
|
475
|
+
* same pattern compiler as HTTP routes. A literal pattern (`/ws/chat`) still
|
|
476
|
+
* matches exactly with empty params; a parameterised pattern
|
|
477
|
+
* (`/ws/rtc/{room}`) matches `/ws/rtc/abc` and yields `{ room: "abc" }`.
|
|
478
|
+
* (Previously WS matching was exact-string only, so `{param}` routes never
|
|
479
|
+
* matched and `connection.params` was always empty.)
|
|
480
|
+
*/
|
|
481
|
+
matchWebSocketWithParams(
|
|
482
|
+
pathname: string,
|
|
483
|
+
): { route: WebSocketRouteDefinition; params: Record<string, string> } | null {
|
|
468
484
|
for (const route of this.wsRoutes) {
|
|
469
|
-
if (route.pattern === pathname) return route;
|
|
485
|
+
if (route.pattern === pathname) return { route, params: {} };
|
|
486
|
+
const { regex, paramNames } = this.compilePattern(route.pattern);
|
|
487
|
+
const match = regex.exec(pathname);
|
|
488
|
+
if (match) {
|
|
489
|
+
const params: Record<string, string> = {};
|
|
490
|
+
paramNames.forEach((name, i) => {
|
|
491
|
+
params[name] = match[i + 1];
|
|
492
|
+
});
|
|
493
|
+
return { route, params };
|
|
494
|
+
}
|
|
470
495
|
}
|
|
471
496
|
return null;
|
|
472
497
|
}
|
|
@@ -550,6 +575,12 @@ export class Router {
|
|
|
550
575
|
return defaultRouter.matchWebSocket(pathname);
|
|
551
576
|
}
|
|
552
577
|
|
|
578
|
+
static matchWebSocketWithParams(
|
|
579
|
+
pathname: string,
|
|
580
|
+
): { route: WebSocketRouteDefinition; params: Record<string, string> } | null {
|
|
581
|
+
return defaultRouter.matchWebSocketWithParams(pathname);
|
|
582
|
+
}
|
|
583
|
+
|
|
553
584
|
/** All WebSocket route definitions on the default global router. */
|
|
554
585
|
static getWebSocketRoutes(): WebSocketRouteDefinition[] {
|
|
555
586
|
return defaultRouter.getWebSocketRoutes();
|
|
@@ -9,7 +9,7 @@ import os from "node:os";
|
|
|
9
9
|
import type { Socket } from "node:net";
|
|
10
10
|
import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
|
|
11
11
|
import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
12
|
-
import {
|
|
12
|
+
import { enforceRouteAuth } from "./authGate.js";
|
|
13
13
|
import { discoverRoutes } from "./routeDiscovery.js";
|
|
14
14
|
import { createRequest } from "./request.js";
|
|
15
15
|
import { createResponse, setDefaultTemplatesDir } from "./response.js";
|
|
@@ -1290,56 +1290,14 @@ ${reset}
|
|
|
1290
1290
|
if (!proceed || res.raw.writableEnded) return;
|
|
1291
1291
|
}
|
|
1292
1292
|
|
|
1293
|
-
// Auth enforcement: secure routes require a valid token
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1293
|
+
// Auth enforcement: secure routes require a valid token. Extracted into
|
|
1294
|
+
// enforceRouteAuth (authGate.ts) so the in-process TestClient enforces
|
|
1295
|
+
// the EXACT same gate (parity with Python #PY2 — a tokenless write must
|
|
1296
|
+
// 401 in tests too, or a green test hides a live 401). Dev admin routes
|
|
1297
|
+
// (/__dev) are always public. Returns true when a 401 was written.
|
|
1296
1298
|
const isDevAdmin = pathname.startsWith("/__dev");
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
1299
|
-
const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
1300
|
-
|
|
1301
|
-
// Priority 1: Authorization Bearer header
|
|
1302
|
-
let resolvedToken = "";
|
|
1303
|
-
let tokenSource: "header" | "body" | "session" | "" = "";
|
|
1304
|
-
|
|
1305
|
-
if (headerToken && validToken(headerToken)) {
|
|
1306
|
-
resolvedToken = headerToken;
|
|
1307
|
-
tokenSource = "header";
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// Priority 2: formToken from request body
|
|
1311
|
-
if (!resolvedToken) {
|
|
1312
|
-
const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
|
|
1313
|
-
if (bodyToken && validToken(bodyToken)) {
|
|
1314
|
-
resolvedToken = bodyToken;
|
|
1315
|
-
tokenSource = "body";
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
// Priority 3: Session token
|
|
1320
|
-
if (!resolvedToken) {
|
|
1321
|
-
const sessionToken = (req as any).session?.get?.("token") as string | undefined;
|
|
1322
|
-
if (sessionToken && validToken(sessionToken)) {
|
|
1323
|
-
resolvedToken = sessionToken;
|
|
1324
|
-
tokenSource = "session";
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
if (!resolvedToken) {
|
|
1329
|
-
res.raw.writeHead(401, { "Content-Type": "application/json" });
|
|
1330
|
-
res.raw.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
req.user = getPayload(resolvedToken) ?? {};
|
|
1335
|
-
|
|
1336
|
-
// When body formToken validates, return a FreshToken header with a refreshed JWT
|
|
1337
|
-
if (tokenSource === "body") {
|
|
1338
|
-
const fresh = refreshToken(resolvedToken);
|
|
1339
|
-
if (fresh) {
|
|
1340
|
-
res.header("FreshToken", fresh);
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1299
|
+
if (enforceRouteAuth(req, res, match, isDevAdmin)) {
|
|
1300
|
+
return;
|
|
1343
1301
|
}
|
|
1344
1302
|
|
|
1345
1303
|
// Inject path params by name into handler arguments, then request/response
|
|
@@ -19,6 +19,7 @@ import { Socket } from "node:net";
|
|
|
19
19
|
import { createRequest } from "./request.js";
|
|
20
20
|
import { createResponse } from "./response.js";
|
|
21
21
|
import { defaultRouter, type Router } from "./router.js";
|
|
22
|
+
import { enforceRouteAuth } from "./authGate.js";
|
|
22
23
|
|
|
23
24
|
export class TestResponse {
|
|
24
25
|
public readonly status: number;
|
|
@@ -167,8 +168,22 @@ export class TestClient {
|
|
|
167
168
|
// Inject route params
|
|
168
169
|
req.params = match.params;
|
|
169
170
|
|
|
170
|
-
//
|
|
171
|
-
|
|
171
|
+
// Route through the REAL auth gate (parity with the live server). A write to
|
|
172
|
+
// an auth-required route (secure-by-default POST/PUT/PATCH/DELETE, or any
|
|
173
|
+
// .secure() route) with no valid token / formToken / session token 401s here
|
|
174
|
+
// exactly as it would in production. The TestClient used to skip this and run
|
|
175
|
+
// the handler directly, so a green test could hide a live 401 — the
|
|
176
|
+
// verification layer lied. A public route (GET, or a write marked .noAuth())
|
|
177
|
+
// passes straight through. When rejected, enforceRouteAuth has written a 401
|
|
178
|
+
// to res.raw, so the collection below reports it just like a handler response.
|
|
179
|
+
// (#PY2 parity)
|
|
180
|
+
const isDevAdmin = cleanPath.startsWith("/__dev");
|
|
181
|
+
const rejected = enforceRouteAuth(req, res, match, isDevAdmin);
|
|
182
|
+
|
|
183
|
+
// Execute handler (only if auth passed)
|
|
184
|
+
if (!rejected) {
|
|
185
|
+
await match.handler(req, res);
|
|
186
|
+
}
|
|
172
187
|
|
|
173
188
|
// Collect response
|
|
174
189
|
const responseBody = Buffer.concat(chunks).toString();
|
|
@@ -993,6 +993,43 @@ class WsRouteManager {
|
|
|
993
993
|
this.connections.get(id)?.rooms.delete(room);
|
|
994
994
|
this.rooms.get(room)?.delete(id);
|
|
995
995
|
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* The live connections currently in a room (open only). Returns the
|
|
999
|
+
* WebSocketConnection objects so callers can read each `.auth` (presence
|
|
1000
|
+
* rosters). Mirrors Python's mgr.get_room_connections / PHP getRoomConnections.
|
|
1001
|
+
*/
|
|
1002
|
+
getRoomConnections(room: string): WebSocketConnection[] {
|
|
1003
|
+
const ids = this.rooms.get(room);
|
|
1004
|
+
if (!ids) return [];
|
|
1005
|
+
const result: WebSocketConnection[] = [];
|
|
1006
|
+
for (const id of ids) {
|
|
1007
|
+
const state = this.connections.get(id);
|
|
1008
|
+
if (state && !state.closed) result.push(state.conn);
|
|
1009
|
+
}
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Broadcast a text frame to every connection in a room, optionally skipping
|
|
1015
|
+
* one connection id (self-exclusion). A dead/slow socket is skipped, never
|
|
1016
|
+
* aborting the rest.
|
|
1017
|
+
*/
|
|
1018
|
+
broadcastToRoom(room: string, message: string, excludeId?: string): void {
|
|
1019
|
+
const ids = this.rooms.get(room);
|
|
1020
|
+
if (!ids) return;
|
|
1021
|
+
const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
|
|
1022
|
+
for (const id of ids) {
|
|
1023
|
+
if (id === excludeId) continue;
|
|
1024
|
+
const state = this.connections.get(id);
|
|
1025
|
+
if (!state || state.closed) continue;
|
|
1026
|
+
try {
|
|
1027
|
+
state.socket.write(frame);
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* dropped */
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
996
1033
|
}
|
|
997
1034
|
|
|
998
1035
|
/** Process-wide manager for integrated-server user WS route connections. */
|
|
@@ -1027,6 +1064,9 @@ function createRouteConnection(
|
|
|
1027
1064
|
send,
|
|
1028
1065
|
sendJson: (data: unknown) => send(JSON.stringify(data)),
|
|
1029
1066
|
broadcast: (message: string) => wsRouteManager.broadcastPath(path, message),
|
|
1067
|
+
broadcastToRoom: (room: string, message: string, excludeSelf = false) =>
|
|
1068
|
+
wsRouteManager.broadcastToRoom(room, message, excludeSelf ? id : undefined),
|
|
1069
|
+
getRoomConnections: (room: string) => wsRouteManager.getRoomConnections(room),
|
|
1030
1070
|
joinRoom: (room: string) => wsRouteManager.joinRoom(id, room),
|
|
1031
1071
|
leaveRoom: (room: string) => wsRouteManager.leaveRoom(id, room),
|
|
1032
1072
|
close: () => {
|
|
@@ -1083,8 +1123,9 @@ function rejectUpgrade(socket: Socket, statusLine: string): void {
|
|
|
1083
1123
|
*/
|
|
1084
1124
|
export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head: Buffer): boolean {
|
|
1085
1125
|
const [reqPath, reqQuery = ""] = (req.url ?? "/").split("?");
|
|
1086
|
-
const
|
|
1087
|
-
if (!
|
|
1126
|
+
const matched = Router.matchWebSocketWithParams(reqPath);
|
|
1127
|
+
if (!matched) return false;
|
|
1128
|
+
const { route, params } = matched;
|
|
1088
1129
|
|
|
1089
1130
|
const wsKey = req.headers["sec-websocket-key"];
|
|
1090
1131
|
if (!wsKey || (typeof wsKey === "string" && wsKey.length === 0)) {
|
|
@@ -1129,7 +1170,7 @@ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head:
|
|
|
1129
1170
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
1130
1171
|
headerMap[k] = Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
|
|
1131
1172
|
}
|
|
1132
|
-
const conn = createRouteConnection(socket, reqPath, headerMap,
|
|
1173
|
+
const conn = createRouteConnection(socket, reqPath, headerMap, params, authPayload);
|
|
1133
1174
|
const state: RouteConnState = { conn, socket, path: reqPath, rooms: new Set(), closed: false };
|
|
1134
1175
|
wsRouteManager.add(state);
|
|
1135
1176
|
|
|
@@ -25,6 +25,18 @@ export interface WebSocketConnection {
|
|
|
25
25
|
sendJson(data: unknown): void;
|
|
26
26
|
/** Broadcast a message to all connections on the same path (path-scoped) */
|
|
27
27
|
broadcast(message: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Broadcast a message to every connection in a room. Pass `excludeSelf: true`
|
|
30
|
+
* to skip this connection (the common signalling/chat relay case). Mirrors
|
|
31
|
+
* Python `connection.broadcast_to_room(room, msg, exclude_self=...)`.
|
|
32
|
+
*/
|
|
33
|
+
broadcastToRoom(roomName: string, message: string, excludeSelf?: boolean): void;
|
|
34
|
+
/**
|
|
35
|
+
* The live connections currently in a room (for presence rosters — read each
|
|
36
|
+
* returned connection's `.auth`). Mirrors the manager room enumeration used
|
|
37
|
+
* by the Python/PHP/Ruby realtime rosters.
|
|
38
|
+
*/
|
|
39
|
+
getRoomConnections(roomName: string): WebSocketConnection[];
|
|
28
40
|
/** Join a room */
|
|
29
41
|
joinRoom(roomName: string): void;
|
|
30
42
|
/** Leave a room */
|
|
@@ -957,7 +957,16 @@ export class BaseModel {
|
|
|
957
957
|
const mappedFields: Record<string, FieldDefinition> = {};
|
|
958
958
|
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
959
959
|
const dbCol = this.getDbColumn(fieldName);
|
|
960
|
-
|
|
960
|
+
// A callable default (e.g. `default: () => new Date()`) is resolved per-row
|
|
961
|
+
// in the constructor; it must NOT reach the adapter DDL builder, where it
|
|
962
|
+
// stringifies to an invalid `DEFAULT () => ...` and silently fails table
|
|
963
|
+
// creation. Drop the default key so no adapter emits it (parity with Python #61).
|
|
964
|
+
if (typeof def.default === "function") {
|
|
965
|
+
const { default: _callableDefault, ...rest } = def;
|
|
966
|
+
mappedFields[dbCol] = rest;
|
|
967
|
+
} else {
|
|
968
|
+
mappedFields[dbCol] = def;
|
|
969
|
+
}
|
|
961
970
|
}
|
|
962
971
|
await adapterCreateTable(db, this.tableName, mappedFields);
|
|
963
972
|
return true;
|
|
@@ -983,7 +992,11 @@ export class BaseModel {
|
|
|
983
992
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
984
993
|
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
985
994
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
986
|
-
|
|
995
|
+
// A callable default (e.g. `default: () => new Date()`) is resolved per-row
|
|
996
|
+
// in the constructor above; it must NOT be emitted into the CREATE TABLE
|
|
997
|
+
// DDL, where String(fn) stringifies to `DEFAULT () => ...` — invalid SQL
|
|
998
|
+
// that silently fails table creation (parity with Python #61).
|
|
999
|
+
if (def.default !== undefined && typeof def.default !== "function") {
|
|
987
1000
|
const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
|
|
988
1001
|
parts.push(`DEFAULT ${dv}`);
|
|
989
1002
|
}
|
|
@@ -83,3 +83,20 @@ export { MongodbAdapter } from "./adapters/mongodb.js";
|
|
|
83
83
|
export type { MongoConfig } from "./adapters/mongodb.js";
|
|
84
84
|
export { OdbcAdapter } from "./adapters/odbc.js";
|
|
85
85
|
export type { OdbcConfig } from "./adapters/odbc.js";
|
|
86
|
+
|
|
87
|
+
// Realtime collaboration mount (calls + chat + files) — parity with the Python master.
|
|
88
|
+
export {
|
|
89
|
+
realtime,
|
|
90
|
+
iceServers,
|
|
91
|
+
type RealtimeOptions,
|
|
92
|
+
LocalStorage,
|
|
93
|
+
S3Storage,
|
|
94
|
+
selectStorage,
|
|
95
|
+
storageKey,
|
|
96
|
+
type StorageBackend,
|
|
97
|
+
Workspace as RealtimeWorkspace,
|
|
98
|
+
Channel as RealtimeChannel,
|
|
99
|
+
ChannelMember as RealtimeChannelMember,
|
|
100
|
+
Message as RealtimeMessage,
|
|
101
|
+
Attachment as RealtimeAttachment,
|
|
102
|
+
} from "./realtime/index.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { realtime, iceServers, type RealtimeOptions } from "./realtime.js";
|
|
2
|
+
export {
|
|
3
|
+
LocalStorage,
|
|
4
|
+
S3Storage,
|
|
5
|
+
selectStorage,
|
|
6
|
+
storageKey,
|
|
7
|
+
type StorageBackend,
|
|
8
|
+
} from "./storage.js";
|
|
9
|
+
export { default as Workspace } from "./models/workspace.js";
|
|
10
|
+
export { default as Channel } from "./models/channel.js";
|
|
11
|
+
export { default as ChannelMember } from "./models/channelMember.js";
|
|
12
|
+
export { default as Message } from "./models/message.js";
|
|
13
|
+
export { default as Attachment } from "./models/attachment.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BaseModel } from "../../baseModel.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attachment - a file linked to a channel (and optionally a message).
|
|
5
|
+
* channel_id scopes the file for permission checks; message_id is null until
|
|
6
|
+
* the file is attached to a posted message. storage_key is the StorageBackend
|
|
7
|
+
* key; the row carries only metadata, never the blob.
|
|
8
|
+
*/
|
|
9
|
+
export default class Attachment extends BaseModel {
|
|
10
|
+
static tableName = "tina4_rt_attachments";
|
|
11
|
+
static fields = {
|
|
12
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
13
|
+
channel_id: { type: "integer" as const },
|
|
14
|
+
message_id: { type: "integer" as const },
|
|
15
|
+
storage_key: { type: "string" as const, required: true, maxLength: 255 },
|
|
16
|
+
filename: { type: "string" as const, maxLength: 255 },
|
|
17
|
+
mime: { type: "string" as const, maxLength: 128 },
|
|
18
|
+
size: { type: "integer" as const },
|
|
19
|
+
thumb_key: { type: "string" as const, maxLength: 255 },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseModel } from "../../baseModel.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Channel - a conversation stream inside a workspace.
|
|
5
|
+
* kind is one of public | private | dm. workspace_id is a plain integer FK
|
|
6
|
+
* column (the realtime handlers query it directly).
|
|
7
|
+
*/
|
|
8
|
+
export default class Channel extends BaseModel {
|
|
9
|
+
static tableName = "tina4_rt_channels";
|
|
10
|
+
static fields = {
|
|
11
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
12
|
+
workspace_id: { type: "integer" as const },
|
|
13
|
+
name: { type: "string" as const, required: true, maxLength: 200 },
|
|
14
|
+
kind: { type: "string" as const, default: "public", maxLength: 20 },
|
|
15
|
+
created_at: { type: "datetime" as const },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseModel } from "../../baseModel.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChannelMember - a user's membership of a channel plus their read cursor.
|
|
5
|
+
* user_id is a string so it holds any identity shape the app puts in the JWT
|
|
6
|
+
* (an integer id, a UUID, an email). last_read_at is the read-receipt cursor.
|
|
7
|
+
*/
|
|
8
|
+
export default class ChannelMember extends BaseModel {
|
|
9
|
+
static tableName = "tina4_rt_channel_members";
|
|
10
|
+
static fields = {
|
|
11
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
12
|
+
channel_id: { type: "integer" as const },
|
|
13
|
+
user_id: { type: "string" as const, required: true, maxLength: 128 },
|
|
14
|
+
role: { type: "string" as const, default: "member", maxLength: 20 },
|
|
15
|
+
last_read_at: { type: "datetime" as const },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BaseModel } from "../../baseModel.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Message - one posted message in a channel.
|
|
5
|
+
* thread_id is null for a top-level message, or the id of the parent message
|
|
6
|
+
* for a threaded reply. edited_at is null until an edit.
|
|
7
|
+
*/
|
|
8
|
+
export default class Message extends BaseModel {
|
|
9
|
+
static tableName = "tina4_rt_messages";
|
|
10
|
+
static fields = {
|
|
11
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
12
|
+
channel_id: { type: "integer" as const },
|
|
13
|
+
user_id: { type: "string" as const, required: true, maxLength: 128 },
|
|
14
|
+
body: { type: "text" as const },
|
|
15
|
+
thread_id: { type: "integer" as const },
|
|
16
|
+
created_at: { type: "datetime" as const },
|
|
17
|
+
edited_at: { type: "datetime" as const },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseModel } from "../../baseModel.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace - the top-level container for channels (a "team" / "org").
|
|
5
|
+
* Framework-owned table: the tina4_rt_ prefix keeps it clear of an app's own
|
|
6
|
+
* domain tables (mirrors tina4_migration / tina4_sequences + the Python
|
|
7
|
+
* master's tina4_rt_* tables). Field keys are snake_case so the columns and
|
|
8
|
+
* JSON keys stay byte-identical to the master (no camelCase mapping needed).
|
|
9
|
+
*/
|
|
10
|
+
export default class Workspace extends BaseModel {
|
|
11
|
+
static tableName = "tina4_rt_workspaces";
|
|
12
|
+
static fields = {
|
|
13
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
14
|
+
name: { type: "string" as const, required: true, maxLength: 200 },
|
|
15
|
+
created_at: { type: "datetime" as const },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
Router,
|
|
4
|
+
HTTP_OK,
|
|
5
|
+
HTTP_CREATED,
|
|
6
|
+
HTTP_BAD_REQUEST,
|
|
7
|
+
HTTP_FORBIDDEN,
|
|
8
|
+
HTTP_NOT_FOUND,
|
|
9
|
+
Log,
|
|
10
|
+
type WebSocketConnection,
|
|
11
|
+
type Tina4Request,
|
|
12
|
+
type Tina4Response,
|
|
13
|
+
} from "@tina4/core";
|
|
14
|
+
import Workspace from "./models/workspace.js";
|
|
15
|
+
import Channel from "./models/channel.js";
|
|
16
|
+
import ChannelMember from "./models/channelMember.js";
|
|
17
|
+
import Message from "./models/message.js";
|
|
18
|
+
import Attachment from "./models/attachment.js";
|
|
19
|
+
import { selectStorage, storageKey, type StorageBackend } from "./storage.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Real-time collaboration mount for Tina4 (Node), parity with the Python
|
|
23
|
+
* master's tina4_python.realtime. A zero-dependency control plane:
|
|
24
|
+
*
|
|
25
|
+
* - calls: a WebRTC signalling relay (mesh) + self-describing ICE-config
|
|
26
|
+
* endpoint. Tina4 carries no media, it only relays the offer/answer/ICE
|
|
27
|
+
* handshake; peers filter by `to`.
|
|
28
|
+
* - chat: persistent channels + messages (framework-owned ORM models), a
|
|
29
|
+
* secured chat WebSocket with live presence / typing / read receipts, and a
|
|
30
|
+
* history endpoint for catch-up-on-reconnect.
|
|
31
|
+
* - files: permissioned upload/download through a pluggable StorageBackend.
|
|
32
|
+
*
|
|
33
|
+
* The wire contract (paths, JSON shapes, env vars, tina4_rt_* tables) is
|
|
34
|
+
* byte-identical to the master. The WS handler is (connection, event, data)
|
|
35
|
+
* with a string event; identity comes from connection.auth (WS) and req.user
|
|
36
|
+
* (HTTP - the router validated the JWT on the secured/auth-required route).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const DEFAULT_STUN = "stun:stun.l.google.com:19302";
|
|
40
|
+
|
|
41
|
+
export interface RealtimeOptions {
|
|
42
|
+
prefix?: string;
|
|
43
|
+
authorize?: (identity: string, channelId: number) => boolean | Promise<boolean>;
|
|
44
|
+
storage?: StorageBackend;
|
|
45
|
+
features?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IceServer {
|
|
49
|
+
urls: string[];
|
|
50
|
+
username?: string;
|
|
51
|
+
credential?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the ICE server list from the environment (STUN always; ephemeral TURN when configured). */
|
|
55
|
+
export function iceServers(): IceServer[] {
|
|
56
|
+
const stun = process.env.TINA4_RTC_STUN_URLS || DEFAULT_STUN;
|
|
57
|
+
const servers: IceServer[] = [{ urls: splitUrls(stun) }];
|
|
58
|
+
|
|
59
|
+
const turnUrl = process.env.TINA4_RTC_TURN_URL;
|
|
60
|
+
const secret = process.env.TINA4_RTC_TURN_SECRET;
|
|
61
|
+
if (turnUrl && secret) {
|
|
62
|
+
const ttl = parseInt(process.env.TINA4_RTC_TURN_TTL || "3600", 10);
|
|
63
|
+
const username = String(Math.floor(Date.now() / 1000) + ttl);
|
|
64
|
+
const credential = createHmac("sha1", secret).update(username).digest("base64");
|
|
65
|
+
servers.push({ urls: splitUrls(turnUrl), username, credential });
|
|
66
|
+
}
|
|
67
|
+
return servers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let _authorize: RealtimeOptions["authorize"] | undefined;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Mount the realtime surface and return the resolved path map (also served
|
|
74
|
+
* from the config endpoint so the client can discover it).
|
|
75
|
+
*
|
|
76
|
+
* Returns a Promise because the framework-owned chat tables are created via the
|
|
77
|
+
* async ORM (Node's DB layer is async) - `await realtime(...)` so the tables
|
|
78
|
+
* exist before the first request. Route registration itself is synchronous.
|
|
79
|
+
*/
|
|
80
|
+
export async function realtime(options: RealtimeOptions = {}): Promise<Record<string, string>> {
|
|
81
|
+
const features = options.features ?? ["calls"];
|
|
82
|
+
const raw = (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
83
|
+
const p = raw ? `/${raw}` : "";
|
|
84
|
+
_authorize = options.authorize;
|
|
85
|
+
|
|
86
|
+
if (features.includes("chat") || features.includes("files")) {
|
|
87
|
+
await ensureChatTables();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const paths: Record<string, string> = { backend: "mesh" };
|
|
91
|
+
if (features.includes("calls")) {
|
|
92
|
+
paths.config = `${p}/api/rtc/config`;
|
|
93
|
+
paths.signalling = `${p}/ws/rtc`;
|
|
94
|
+
}
|
|
95
|
+
if (features.includes("chat")) {
|
|
96
|
+
paths.config ??= `${p}/api/rtc/config`;
|
|
97
|
+
paths.chat = `${p}/ws/chat`;
|
|
98
|
+
paths.messages = `${p}/api/channels`;
|
|
99
|
+
}
|
|
100
|
+
if (features.includes("files")) {
|
|
101
|
+
paths.config ??= `${p}/api/rtc/config`;
|
|
102
|
+
paths.files = `${p}/api/files`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (paths.config) registerConfig(paths, features);
|
|
106
|
+
if (features.includes("calls")) registerCalls(paths);
|
|
107
|
+
if (features.includes("chat")) registerChat(paths);
|
|
108
|
+
if (features.includes("files")) registerFiles(paths, options.storage);
|
|
109
|
+
|
|
110
|
+
return paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── route registration ──────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function registerConfig(paths: Record<string, string>, features: string[]): void {
|
|
116
|
+
Router.get(paths.config, async (_req: Tina4Request, res: Tina4Response) => {
|
|
117
|
+
const body: Record<string, unknown> = { backend: "mesh" };
|
|
118
|
+
if (features.includes("calls")) {
|
|
119
|
+
body.iceServers = iceServers();
|
|
120
|
+
body.signalling = `${paths.signalling}/{room}`;
|
|
121
|
+
}
|
|
122
|
+
if (features.includes("chat")) {
|
|
123
|
+
body.chat = `${paths.chat}/{channel}`;
|
|
124
|
+
body.messages = `${paths.messages}/{id}/messages`;
|
|
125
|
+
}
|
|
126
|
+
if (features.includes("files")) body.files = paths.files;
|
|
127
|
+
return res.json(body, HTTP_OK);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function registerCalls(paths: Record<string, string>): void {
|
|
132
|
+
// WebRTC signalling relay (mesh). Public room; Tina4 never parses the SDP,
|
|
133
|
+
// peers filter by `to`. The room id comes from the {room} path param.
|
|
134
|
+
Router.websocket(`${paths.signalling}/{room}`, (connection, event, data) => {
|
|
135
|
+
const room = connection.params.room ?? "";
|
|
136
|
+
if (!room) return;
|
|
137
|
+
const key = `rtc:${room}`;
|
|
138
|
+
if (event === "open") {
|
|
139
|
+
connection.joinRoom(key);
|
|
140
|
+
} else if (event === "message") {
|
|
141
|
+
connection.broadcastToRoom(key, data, true);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function registerChat(paths: Record<string, string>): void {
|
|
147
|
+
Router.websocket(`${paths.chat}/{channel}`, chatHandler, { secured: true });
|
|
148
|
+
|
|
149
|
+
Router.get(`${paths.messages}/{id}/messages`, async (req: Tina4Request, res: Tina4Response) => {
|
|
150
|
+
const identity = identityOf(req.user);
|
|
151
|
+
const channelId = parseInt(String(req.params.id ?? ""), 10);
|
|
152
|
+
if (!Number.isFinite(channelId) || channelId <= 0) {
|
|
153
|
+
return res.json({ error: "invalid channel id" }, HTTP_BAD_REQUEST);
|
|
154
|
+
}
|
|
155
|
+
if (!(await authorized(identity, channelId))) {
|
|
156
|
+
return res.json({ error: "forbidden" }, HTTP_FORBIDDEN);
|
|
157
|
+
}
|
|
158
|
+
return res.json(await history(channelId, req.query ?? {}), HTTP_OK);
|
|
159
|
+
}).secure();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function registerFiles(paths: Record<string, string>, storageOpt?: StorageBackend): void {
|
|
163
|
+
const store = selectStorage(storageOpt);
|
|
164
|
+
|
|
165
|
+
// POST is auth-required by default (mirrors GET being public).
|
|
166
|
+
Router.post(paths.files, async (req: Tina4Request, res: Tina4Response) => {
|
|
167
|
+
const identity = identityOf(req.user);
|
|
168
|
+
const channelId = parseInt(String(formValue(req, "channel_id") ?? ""), 10);
|
|
169
|
+
if (!Number.isFinite(channelId) || channelId <= 0) {
|
|
170
|
+
return res.json({ error: "channel_id is required" }, HTTP_BAD_REQUEST);
|
|
171
|
+
}
|
|
172
|
+
if (!(await authorized(identity, channelId))) {
|
|
173
|
+
return res.json({ error: "forbidden" }, HTTP_FORBIDDEN);
|
|
174
|
+
}
|
|
175
|
+
const upload = req.files?.file;
|
|
176
|
+
const file = Array.isArray(upload) ? upload[0] : upload;
|
|
177
|
+
if (!file) return res.json({ error: "no file uploaded (field 'file')" }, HTTP_BAD_REQUEST);
|
|
178
|
+
|
|
179
|
+
const saved = await storeUpload(store, channelId, file as { filename?: string; type?: string; content?: Buffer });
|
|
180
|
+
const direct = await store.url(saved.key);
|
|
181
|
+
saved.url = direct || `${paths.files}/${saved.key}`;
|
|
182
|
+
return res.json(saved, HTTP_CREATED);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
Router.get(`${paths.files}/{key}`, async (req: Tina4Request, res: Tina4Response) => {
|
|
186
|
+
const identity = identityOf(req.user);
|
|
187
|
+
const key = String(req.params.key ?? "");
|
|
188
|
+
const rows = await Attachment.where("storage_key = ?", [key], 1);
|
|
189
|
+
const att = rows[0] as { channel_id?: number; filename?: string; mime?: string } | undefined;
|
|
190
|
+
if (!att) return res.json({ error: "not found" }, HTTP_NOT_FOUND);
|
|
191
|
+
if (!(await authorized(identity, Number(att.channel_id)))) {
|
|
192
|
+
return res.json({ error: "forbidden" }, HTTP_FORBIDDEN);
|
|
193
|
+
}
|
|
194
|
+
const direct = await store.url(key);
|
|
195
|
+
if (direct) return res.redirect(direct, 302);
|
|
196
|
+
const bytes = await store.get(key);
|
|
197
|
+
if (bytes === null) return res.json({ error: "not found" }, HTTP_NOT_FOUND);
|
|
198
|
+
res.header("Content-Disposition", `inline; filename="${att.filename || key}"`);
|
|
199
|
+
return res.send(bytes, HTTP_OK, att.mime || "application/octet-stream");
|
|
200
|
+
}).secure();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── WebSocket chat handler ────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
async function chatHandler(connection: WebSocketConnection, event: "open" | "message" | "close", data: string): Promise<void> {
|
|
206
|
+
const rawChannel = connection.params.channel ?? "";
|
|
207
|
+
if (!/^\d+$/.test(rawChannel)) return; // framework channels are addressed by integer id
|
|
208
|
+
const channelId = parseInt(rawChannel, 10);
|
|
209
|
+
const identity = identityOf(connection.auth);
|
|
210
|
+
const key = `chat:${channelId}`;
|
|
211
|
+
|
|
212
|
+
if (event === "open") {
|
|
213
|
+
if (!(await authorized(identity, channelId))) {
|
|
214
|
+
connection.sendJson({ type: "error", error: "not a member of this channel" });
|
|
215
|
+
connection.close();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
connection.joinRoom(key);
|
|
219
|
+
connection.sendJson({ type: "presence", event: "roster", users: roster(connection, key) });
|
|
220
|
+
connection.broadcastToRoom(key, JSON.stringify({ type: "presence", event: "join", user_id: identity }), true);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (event === "close") {
|
|
225
|
+
connection.broadcastToRoom(key, JSON.stringify({ type: "presence", event: "leave", user_id: identity }), true);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Re-check membership on every inbound frame (it can be revoked mid-session).
|
|
230
|
+
if (!(await authorized(identity, channelId))) return;
|
|
231
|
+
const payload = parseJson(data);
|
|
232
|
+
if (!payload || typeof payload !== "object") return;
|
|
233
|
+
|
|
234
|
+
const kind = (payload as { type?: string }).type ?? "message";
|
|
235
|
+
if (kind === "typing") {
|
|
236
|
+
connection.broadcastToRoom(key, JSON.stringify({ type: "typing", user_id: identity }), true);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (kind === "read") {
|
|
240
|
+
await markRead(channelId, identity);
|
|
241
|
+
connection.broadcastToRoom(key, JSON.stringify({ type: "read", user_id: identity, at: nowIso() }), true);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (kind === "message") {
|
|
245
|
+
const body = String((payload as { body?: unknown }).body ?? "").trim();
|
|
246
|
+
if (!body) return;
|
|
247
|
+
const saved = await persistMessage(channelId, identity, body, (payload as { thread_id?: unknown }).thread_id);
|
|
248
|
+
// Deliver to everyone INCLUDING the sender (id + timestamp reconcile).
|
|
249
|
+
if (saved) connection.broadcastToRoom(key, JSON.stringify({ type: "message", message: saved }));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── helpers ───────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/** Extract a stable string user identity from a verified JWT payload. */
|
|
256
|
+
function identityOf(auth: Record<string, unknown> | null | undefined): string | null {
|
|
257
|
+
if (!auth || typeof auth !== "object") return null;
|
|
258
|
+
for (const claim of ["user_id", "sub", "id"]) {
|
|
259
|
+
const v = (auth as Record<string, unknown>)[claim];
|
|
260
|
+
if (v !== undefined && v !== null) return String(v);
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Shared membership guard for chat channels and file access. */
|
|
266
|
+
async function authorized(identity: string | null, channelId: number): Promise<boolean> {
|
|
267
|
+
if (identity === null) return false;
|
|
268
|
+
if (_authorize) return Boolean(await _authorize(identity, channelId));
|
|
269
|
+
try {
|
|
270
|
+
return (await ChannelMember.count("channel_id = ? AND user_id = ?", [channelId, identity])) > 0;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
Log.error(`realtime chat authorize check failed: ${(e as Error).message}`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Presence roster: the distinct authenticated identities in a room, sorted. */
|
|
278
|
+
function roster(connection: WebSocketConnection, key: string): string[] {
|
|
279
|
+
const seen = new Set<string>();
|
|
280
|
+
for (const c of connection.getRoomConnections(key)) {
|
|
281
|
+
const id = identityOf(c.auth);
|
|
282
|
+
if (id) seen.add(id);
|
|
283
|
+
}
|
|
284
|
+
return Array.from(seen).sort();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formValue(req: Tina4Request, name: string): unknown {
|
|
288
|
+
const body = req.body as Record<string, unknown> | undefined;
|
|
289
|
+
if (body && typeof body === "object" && name in body) return body[name];
|
|
290
|
+
return (req.query as Record<string, unknown> | undefined)?.[name] ?? (req.params as Record<string, unknown> | undefined)?.[name];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseJson(data: string): unknown {
|
|
294
|
+
try {
|
|
295
|
+
return typeof data === "string" ? JSON.parse(data) : data;
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function nowIso(): string {
|
|
302
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function persistMessage(channelId: number, identity: string | null, body: string, threadId: unknown): Promise<Record<string, unknown> | null> {
|
|
306
|
+
const thread = threadId === null || threadId === undefined || threadId === "" ? null : parseInt(String(threadId), 10);
|
|
307
|
+
const createdAt = nowIso();
|
|
308
|
+
const msg = new Message({
|
|
309
|
+
channel_id: channelId,
|
|
310
|
+
user_id: String(identity),
|
|
311
|
+
body,
|
|
312
|
+
thread_id: thread,
|
|
313
|
+
created_at: createdAt,
|
|
314
|
+
});
|
|
315
|
+
if ((await msg.save()) === false) {
|
|
316
|
+
Log.error(`realtime chat: failed to persist message in channel ${channelId}`);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
id: (msg as { id?: number }).id,
|
|
321
|
+
channel_id: channelId,
|
|
322
|
+
user_id: String(identity),
|
|
323
|
+
body,
|
|
324
|
+
thread_id: thread,
|
|
325
|
+
created_at: createdAt,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function markRead(channelId: number, identity: string | null): Promise<void> {
|
|
330
|
+
try {
|
|
331
|
+
const rows = await ChannelMember.where("channel_id = ? AND user_id = ?", [channelId, identity], 1);
|
|
332
|
+
const member = rows[0] as (ChannelMember & { last_read_at?: string }) | undefined;
|
|
333
|
+
if (member) {
|
|
334
|
+
(member as { last_read_at?: string }).last_read_at = nowIso();
|
|
335
|
+
await member.save();
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
Log.error(`realtime chat: read-receipt update failed: ${(e as Error).message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Messages for a channel, newest-first, paged by a `before` cursor. */
|
|
343
|
+
async function history(channelId: number, query: Record<string, unknown>): Promise<Record<string, unknown>[]> {
|
|
344
|
+
let limit = parseInt(String(query.limit ?? "50"), 10);
|
|
345
|
+
if (!Number.isFinite(limit) || limit <= 0) limit = 50;
|
|
346
|
+
limit = Math.min(Math.max(limit, 1), 200);
|
|
347
|
+
const before = query.before;
|
|
348
|
+
|
|
349
|
+
let clause = "channel_id = ?";
|
|
350
|
+
const params: unknown[] = [channelId];
|
|
351
|
+
if (before !== undefined && before !== null && String(before) !== "") {
|
|
352
|
+
clause += " AND id < ?";
|
|
353
|
+
params.push(parseInt(String(before), 10));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const rows = (await Message.where(clause, params, 1000)) as Array<Record<string, unknown>>;
|
|
357
|
+
rows.sort((a, b) => Number(b.id ?? 0) - Number(a.id ?? 0));
|
|
358
|
+
return rows.slice(0, limit).map((m) => ({
|
|
359
|
+
id: m.id,
|
|
360
|
+
channel_id: m.channel_id,
|
|
361
|
+
user_id: m.user_id,
|
|
362
|
+
body: m.body,
|
|
363
|
+
thread_id: m.thread_id,
|
|
364
|
+
created_at: m.created_at,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function storeUpload(
|
|
369
|
+
store: StorageBackend,
|
|
370
|
+
channelId: number,
|
|
371
|
+
upload: { filename?: string; type?: string; content?: Buffer },
|
|
372
|
+
): Promise<Record<string, unknown> & { key: string; url?: string }> {
|
|
373
|
+
const filename = upload.filename || "file";
|
|
374
|
+
const mime = upload.type || "application/octet-stream";
|
|
375
|
+
const content = upload.content ?? Buffer.alloc(0);
|
|
376
|
+
const key = storageKey(filename);
|
|
377
|
+
await store.put(key, content, mime);
|
|
378
|
+
const att = new Attachment({ channel_id: channelId, storage_key: key, filename, mime, size: content.length });
|
|
379
|
+
await att.save();
|
|
380
|
+
return { id: (att as { id?: number }).id, key, filename, mime, size: content.length };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function ensureChatTables(): Promise<boolean> {
|
|
384
|
+
try {
|
|
385
|
+
for (const model of [Workspace, Channel, ChannelMember, Message, Attachment]) {
|
|
386
|
+
await model.createTable();
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
} catch (e) {
|
|
390
|
+
Log.error(
|
|
391
|
+
`realtime chat could not create its tables (${(e as Error).message}). Chat needs a bound ` +
|
|
392
|
+
"database - initDatabase()/bindDatabase() before realtime({ features: [...] }).",
|
|
393
|
+
);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function splitUrls(csv: string): string[] {
|
|
399
|
+
return csv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
400
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { Log } from "@tina4/core";
|
|
6
|
+
|
|
7
|
+
// ESM has no global `require`; create one so the OPTIONAL aws-sdk can be loaded
|
|
8
|
+
// synchronously in S3Storage's constructor. A missing driver throws here and
|
|
9
|
+
// selectStorage() catches it, degrading to local storage.
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pluggable file storage for the realtime "files" feature. StorageBackend is
|
|
14
|
+
* the interface; LocalStorage (default, zero-dependency, filesystem) and
|
|
15
|
+
* S3Storage (opt-in, S3-compatible via @aws-sdk/client-s3) are the shipped
|
|
16
|
+
* implementations. Selection mirrors the cache/session/queue backend pattern:
|
|
17
|
+
* TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with a
|
|
18
|
+
* graceful fallback to local if an s3 backend cannot be constructed - a real
|
|
19
|
+
* persistent store, never a silent no-op. Parity with Python's storage.py.
|
|
20
|
+
*/
|
|
21
|
+
export interface StorageBackend {
|
|
22
|
+
put(key: string, data: Buffer | string, mime?: string): void | Promise<void>;
|
|
23
|
+
get(key: string): Buffer | null | Promise<Buffer | null>;
|
|
24
|
+
/** A directly-fetchable URL when the backend supports one, else null. */
|
|
25
|
+
url(key: string, ttl?: number): string | null | Promise<string | null>;
|
|
26
|
+
delete(key: string): void | Promise<void>;
|
|
27
|
+
exists(key: string): boolean | Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const UNSAFE = /[^A-Za-z0-9]/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate an opaque, collision-free storage key, preserving the extension.
|
|
34
|
+
* The key carries no user-controlled path segment, so it can never traverse
|
|
35
|
+
* outside the storage root.
|
|
36
|
+
*/
|
|
37
|
+
export function storageKey(filename = ""): string {
|
|
38
|
+
let ext = "";
|
|
39
|
+
if (filename && filename.includes(".")) {
|
|
40
|
+
const raw = filename.slice(filename.lastIndexOf(".") + 1);
|
|
41
|
+
const clean = raw.replace(UNSAFE, "").slice(0, 12);
|
|
42
|
+
if (clean) ext = `.${clean}`;
|
|
43
|
+
}
|
|
44
|
+
return `${randomBytes(16).toString("hex")}${ext}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Zero-dependency filesystem store. The default backend. */
|
|
48
|
+
export class LocalStorage implements StorageBackend {
|
|
49
|
+
private directory: string;
|
|
50
|
+
|
|
51
|
+
constructor(directory?: string) {
|
|
52
|
+
this.directory = resolve(directory || process.env.TINA4_STORAGE_DIR || "data/rt_storage");
|
|
53
|
+
mkdirSync(this.directory, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve inside the root and reject any traversal attempt.
|
|
57
|
+
private pathFor(key: string): string {
|
|
58
|
+
const target = resolve(this.directory, key);
|
|
59
|
+
if (target !== this.directory && !target.startsWith(this.directory + sep)) {
|
|
60
|
+
throw new Error(`unsafe storage key: ${JSON.stringify(key)}`);
|
|
61
|
+
}
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
put(key: string, data: Buffer | string): void {
|
|
66
|
+
writeFileSync(this.pathFor(key), data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get(key: string): Buffer | null {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(this.pathFor(key));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// No direct URL: the permissioned app download route serves local files.
|
|
78
|
+
url(): string | null {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
delete(key: string): void {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(this.pathFor(key));
|
|
85
|
+
} catch {
|
|
86
|
+
/* not found / unsafe — nothing to remove */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
exists(key: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
return statSync(this.pathFor(key)).isFile();
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* S3-compatible store (AWS S3, MinIO, ...). Opt-in; needs @aws-sdk/client-s3.
|
|
101
|
+
* Presigned GET URLs let clients fetch large blobs straight from object
|
|
102
|
+
* storage instead of streaming through the app.
|
|
103
|
+
*/
|
|
104
|
+
export class S3Storage implements StorageBackend {
|
|
105
|
+
private client: any;
|
|
106
|
+
private bucket: string;
|
|
107
|
+
|
|
108
|
+
constructor(opts: { endpoint?: string; key?: string; secret?: string; bucket?: string; region?: string } = {}) {
|
|
109
|
+
this.bucket = opts.bucket || process.env.TINA4_STORAGE_BUCKET || "";
|
|
110
|
+
if (!this.bucket) throw new Error("S3Storage requires TINA4_STORAGE_BUCKET");
|
|
111
|
+
// Loaded lazily; a missing driver throws here and selectStorage falls back.
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
113
|
+
const { S3Client } = require("@aws-sdk/client-s3");
|
|
114
|
+
const endpoint = opts.endpoint || process.env.TINA4_STORAGE_URL;
|
|
115
|
+
this.client = new S3Client({
|
|
116
|
+
endpoint: endpoint || undefined,
|
|
117
|
+
forcePathStyle: true,
|
|
118
|
+
region: opts.region || process.env.TINA4_STORAGE_REGION || "us-east-1",
|
|
119
|
+
credentials: {
|
|
120
|
+
accessKeyId: opts.key || process.env.TINA4_STORAGE_KEY || "",
|
|
121
|
+
secretAccessKey: opts.secret || process.env.TINA4_STORAGE_SECRET || "",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async put(key: string, data: Buffer | string, mime = "application/octet-stream"): Promise<void> {
|
|
127
|
+
const { PutObjectCommand } = require("@aws-sdk/client-s3");
|
|
128
|
+
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: mime }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async get(key: string): Promise<Buffer | null> {
|
|
132
|
+
try {
|
|
133
|
+
const { GetObjectCommand } = require("@aws-sdk/client-s3");
|
|
134
|
+
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
135
|
+
const chunks: Buffer[] = [];
|
|
136
|
+
for await (const chunk of res.Body as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk));
|
|
137
|
+
return Buffer.concat(chunks);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async url(key: string, ttl = 3600): Promise<string | null> {
|
|
144
|
+
const { GetObjectCommand } = require("@aws-sdk/client-s3");
|
|
145
|
+
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
|
146
|
+
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttl });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async delete(key: string): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
const { DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
|
152
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
153
|
+
} catch (e) {
|
|
154
|
+
Log.error(`S3Storage delete failed for ${key}: ${(e as Error).message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async exists(key: string): Promise<boolean> {
|
|
159
|
+
try {
|
|
160
|
+
const { HeadObjectCommand } = require("@aws-sdk/client-s3");
|
|
161
|
+
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the storage backend from an explicit instance or the environment.
|
|
171
|
+
* Falls back to LocalStorage (logging why) when an s3 backend cannot be built
|
|
172
|
+
* (driver missing or config incomplete) - a real store, never a silent no-op.
|
|
173
|
+
*/
|
|
174
|
+
export function selectStorage(storage?: StorageBackend): StorageBackend {
|
|
175
|
+
if (storage) return storage;
|
|
176
|
+
const name = (process.env.TINA4_STORAGE_BACKEND || "local").toLowerCase();
|
|
177
|
+
if (name === "s3") {
|
|
178
|
+
try {
|
|
179
|
+
return new S3Storage();
|
|
180
|
+
} catch (e) {
|
|
181
|
+
Log.warning(
|
|
182
|
+
`realtime files: S3 storage unavailable (${(e as Error).message}); ` +
|
|
183
|
+
"falling back to local filesystem storage.",
|
|
184
|
+
);
|
|
185
|
+
return new LocalStorage();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return new LocalStorage();
|
|
189
|
+
}
|