tina4ruby 3.13.56 → 3.13.57
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.
- checksums.yaml +4 -4
- data/lib/tina4/orm.rb +6 -1
- data/lib/tina4/public/js/tina4js.min.js +3 -3
- data/lib/tina4/rack_app.rb +71 -52
- data/lib/tina4/realtime/attachment.rb +22 -0
- data/lib/tina4/realtime/channel.rb +19 -0
- data/lib/tina4/realtime/channel_member.rb +18 -0
- data/lib/tina4/realtime/local_storage.rb +57 -0
- data/lib/tina4/realtime/message.rb +20 -0
- data/lib/tina4/realtime/s3_storage.rb +60 -0
- data/lib/tina4/realtime/storage.rb +52 -0
- data/lib/tina4/realtime/storage_backend.rb +32 -0
- data/lib/tina4/realtime/workspace.rb +18 -0
- data/lib/tina4/realtime.rb +400 -0
- data/lib/tina4/request.rb +26 -2
- data/lib/tina4/test_client.rb +10 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +7 -1
- data/lib/tina4.rb +1 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4bb69292bcdb7035a62af766ea15bd53024a66cd81876e20759e63406438e778
|
|
4
|
+
data.tar.gz: 1febc84f01f5e99f383242fee3b1354bb285350b34369c33d88fba00ac05ea32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ea7ac0c108326d7d4fed075b8750190cc3e3ce48dad0536766452cab6651a1c83da5007de05c894825366e444bdf55225e528f30eb43ce4822fbf09f7a3744e
|
|
7
|
+
data.tar.gz: d5b933129a85f7c9a281a8a5adaebc0ac9e1f6ac3fbefa57d0142d6ccb3e935374d4f041de2a1175c11376c63af31b83eaa3f60526375396a5f75086ce6cd065
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -462,7 +462,12 @@ module Tina4
|
|
|
462
462
|
# a dict/list default is an application-level concern, applied per
|
|
463
463
|
# instance, not a portable SQL literal (PG needs a ::jsonb cast, MySQL
|
|
464
464
|
# an expression default). The instance still gets its default at new.
|
|
465
|
-
|
|
465
|
+
# A callable default (e.g. `datetime_field :created_at, default: -> { Time.now }`)
|
|
466
|
+
# is resolved per-row at instance creation; it must NOT reach the DDL, where
|
|
467
|
+
# default_literal would stringify the Proc to `DEFAULT #<Proc:...>` — invalid
|
|
468
|
+
# SQL that silently fails table creation (parity with the Python master, #61).
|
|
469
|
+
callable_default = opts[:default].respond_to?(:call) && !opts[:default].is_a?(Class)
|
|
470
|
+
if opts[:default] && !opts[:auto_increment] && opts[:type] != :json && !callable_default
|
|
466
471
|
parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}"
|
|
467
472
|
end
|
|
468
473
|
col_defs << parts.join(" ")
|
|
@@ -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);})();
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -298,56 +298,12 @@ module Tina4
|
|
|
298
298
|
return handle_403(env["PATH_INFO"] || "/") unless auth_result
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
-
# Secure-by-default: enforce bearer-token auth on write routes
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
308
|
-
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
309
|
-
token = Regexp.last_match(1)
|
|
310
|
-
token_source = :header
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
|
|
314
|
-
if token.nil?
|
|
315
|
-
body_str = _read_rack_body(env)
|
|
316
|
-
form_token = _extract_form_token(body_str, env)
|
|
317
|
-
if form_token && !form_token.empty?
|
|
318
|
-
token = form_token
|
|
319
|
-
token_source = :body
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# Priority 3: Session token (for secured GET routes after login)
|
|
324
|
-
if token.nil?
|
|
325
|
-
session = Tina4::Session.new(env)
|
|
326
|
-
session_token = session.get("token")
|
|
327
|
-
if session_token && !session_token.empty?
|
|
328
|
-
token = session_token
|
|
329
|
-
token_source = :session
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
# API_KEY bypass — matches tina4_python behavior
|
|
334
|
-
api_key = ENV["TINA4_API_KEY"]
|
|
335
|
-
if api_key && !api_key.empty? && token == api_key
|
|
336
|
-
env["tina4.auth_payload"] = { "api_key" => true }
|
|
337
|
-
elsif token
|
|
338
|
-
unless Tina4::Auth.valid_token(token)
|
|
339
|
-
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
340
|
-
end
|
|
341
|
-
env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
|
|
342
|
-
|
|
343
|
-
# When body formToken validates, store a refreshed token for the FreshToken response header
|
|
344
|
-
if token_source == :body
|
|
345
|
-
env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token)
|
|
346
|
-
end
|
|
347
|
-
else
|
|
348
|
-
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
349
|
-
end
|
|
350
|
-
end
|
|
301
|
+
# Secure-by-default: enforce bearer-token auth on write routes.
|
|
302
|
+
# Extracted into a class method so the in-process TestClient enforces the
|
|
303
|
+
# EXACT same gate (parity with Python #PY2 — a tokenless write must 401 in
|
|
304
|
+
# tests too, or a green test hides a live 401 and the verification lies).
|
|
305
|
+
unauthorized = self.class.enforce_route_auth(env, route)
|
|
306
|
+
return unauthorized if unauthorized
|
|
351
307
|
|
|
352
308
|
request = Tina4::Request.new(env, path_params)
|
|
353
309
|
request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
|
|
@@ -1158,7 +1114,70 @@ module Tina4
|
|
|
1158
1114
|
|
|
1159
1115
|
|
|
1160
1116
|
# Read and rewind the Rack input body. Returns the raw body string.
|
|
1161
|
-
|
|
1117
|
+
# Enforce the secure-by-default write-route auth gate.
|
|
1118
|
+
#
|
|
1119
|
+
# Returns nil when the route is public OR a valid token is present (and, as a
|
|
1120
|
+
# side effect, sets env["tina4.auth_payload"] and, for a body formToken,
|
|
1121
|
+
# env["tina4.fresh_token"]). Returns a Rack 401 tuple when an auth-required
|
|
1122
|
+
# route has no valid token.
|
|
1123
|
+
#
|
|
1124
|
+
# A CLASS method on purpose: both the live RackApp#handle_route and the
|
|
1125
|
+
# in-process TestClient call it, so the test surface enforces the identical
|
|
1126
|
+
# gate as production (Python #PY2 parity). Instantiating a RackApp just to
|
|
1127
|
+
# reach the check would run full boot/route-discovery — this needs none of it.
|
|
1128
|
+
def self.enforce_route_auth(env, route)
|
|
1129
|
+
return nil unless route.auth_required
|
|
1130
|
+
|
|
1131
|
+
token = nil
|
|
1132
|
+
token_source = nil # :header, :body, :session
|
|
1133
|
+
|
|
1134
|
+
# Priority 1: Authorization Bearer header
|
|
1135
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
1136
|
+
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
1137
|
+
token = Regexp.last_match(1)
|
|
1138
|
+
token_source = :header
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
|
|
1142
|
+
if token.nil?
|
|
1143
|
+
body_str = _read_rack_body(env)
|
|
1144
|
+
form_token = _extract_form_token(body_str, env)
|
|
1145
|
+
if form_token && !form_token.empty?
|
|
1146
|
+
token = form_token
|
|
1147
|
+
token_source = :body
|
|
1148
|
+
end
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
# Priority 3: Session token (for secured GET routes after login)
|
|
1152
|
+
if token.nil?
|
|
1153
|
+
session = Tina4::Session.new(env)
|
|
1154
|
+
session_token = session.get("token")
|
|
1155
|
+
if session_token && !session_token.empty?
|
|
1156
|
+
token = session_token
|
|
1157
|
+
token_source = :session
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
1162
|
+
api_key = ENV["TINA4_API_KEY"]
|
|
1163
|
+
if api_key && !api_key.empty? && token == api_key
|
|
1164
|
+
env["tina4.auth_payload"] = { "api_key" => true }
|
|
1165
|
+
elsif token
|
|
1166
|
+
unless Tina4::Auth.valid_token(token)
|
|
1167
|
+
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
1168
|
+
end
|
|
1169
|
+
env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
|
|
1170
|
+
|
|
1171
|
+
# When body formToken validates, store a refreshed token for the FreshToken response header
|
|
1172
|
+
env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
|
|
1173
|
+
else
|
|
1174
|
+
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
nil
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def self._read_rack_body(env)
|
|
1162
1181
|
input = env["rack.input"]
|
|
1163
1182
|
return "" unless input
|
|
1164
1183
|
input.rewind if input.respond_to?(:rewind)
|
|
@@ -1169,7 +1188,7 @@ module Tina4
|
|
|
1169
1188
|
|
|
1170
1189
|
# Extract a formToken from the request body.
|
|
1171
1190
|
# Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).
|
|
1172
|
-
def _extract_form_token(body_str, env)
|
|
1191
|
+
def self._extract_form_token(body_str, env)
|
|
1173
1192
|
return nil if body_str.nil? || body_str.empty?
|
|
1174
1193
|
|
|
1175
1194
|
content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# Attachment - a file linked to a channel (and optionally a message).
|
|
6
|
+
# channel_id scopes the file for permission checks; message_id is nil until
|
|
7
|
+
# the file is attached to a posted message. storage_key is the StorageBackend
|
|
8
|
+
# key; the row carries only metadata, never the blob.
|
|
9
|
+
class Attachment < Tina4::ORM
|
|
10
|
+
table_name "tina4_rt_attachments"
|
|
11
|
+
|
|
12
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
13
|
+
integer_field :channel_id
|
|
14
|
+
integer_field :message_id
|
|
15
|
+
string_field :storage_key, nullable: false, length: 255
|
|
16
|
+
string_field :filename, length: 255
|
|
17
|
+
string_field :mime, length: 128
|
|
18
|
+
integer_field :size
|
|
19
|
+
string_field :thumb_key, length: 255
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# Channel - a conversation stream inside a workspace.
|
|
6
|
+
# kind is one of public | private | dm. workspace_id is a plain integer FK
|
|
7
|
+
# column (the realtime handlers query it directly; no relationship wiring
|
|
8
|
+
# is needed for the control plane).
|
|
9
|
+
class Channel < Tina4::ORM
|
|
10
|
+
table_name "tina4_rt_channels"
|
|
11
|
+
|
|
12
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
13
|
+
integer_field :workspace_id
|
|
14
|
+
string_field :name, nullable: false, length: 200
|
|
15
|
+
string_field :kind, default: "public", length: 20
|
|
16
|
+
datetime_field :created_at
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# ChannelMember - a user's membership of a channel plus their read cursor.
|
|
6
|
+
# user_id is a string so it holds any identity shape the app puts in the JWT
|
|
7
|
+
# (an integer id, a UUID, an email). last_read_at is the read-receipt cursor.
|
|
8
|
+
class ChannelMember < Tina4::ORM
|
|
9
|
+
table_name "tina4_rt_channel_members"
|
|
10
|
+
|
|
11
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
12
|
+
integer_field :channel_id
|
|
13
|
+
string_field :user_id, nullable: false, length: 128
|
|
14
|
+
string_field :role, default: "member", length: 20
|
|
15
|
+
datetime_field :last_read_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module Realtime
|
|
7
|
+
# Zero-dependency filesystem store. The default backend.
|
|
8
|
+
class LocalStorage < StorageBackend
|
|
9
|
+
def initialize(directory = nil)
|
|
10
|
+
super()
|
|
11
|
+
@directory = File.expand_path(directory || ENV["TINA4_STORAGE_DIR"] || "data/rt_storage")
|
|
12
|
+
FileUtils.mkdir_p(@directory)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def put(key, data, _mime = "application/octet-stream")
|
|
16
|
+
File.binwrite(path_for(key), data)
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(key)
|
|
21
|
+
File.binread(path_for(key))
|
|
22
|
+
rescue Errno::ENOENT, ArgumentError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# No direct URL: the permissioned app download route serves local files.
|
|
27
|
+
def url(_key, _ttl = 3600)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(key)
|
|
32
|
+
File.delete(path_for(key))
|
|
33
|
+
nil
|
|
34
|
+
rescue Errno::ENOENT, ArgumentError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exists?(key)
|
|
39
|
+
File.file?(path_for(key))
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Resolve inside the root and reject any traversal attempt.
|
|
47
|
+
def path_for(key)
|
|
48
|
+
target = File.expand_path(File.join(@directory, key.to_s))
|
|
49
|
+
unless target == @directory || target.start_with?(@directory + File::SEPARATOR)
|
|
50
|
+
raise ArgumentError, "unsafe storage key: #{key.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
target
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# Message - one posted message in a channel.
|
|
6
|
+
# thread_id is nil for a top-level message, or the id of the parent message
|
|
7
|
+
# for a threaded reply. edited_at is nil until an edit.
|
|
8
|
+
class Message < Tina4::ORM
|
|
9
|
+
table_name "tina4_rt_messages"
|
|
10
|
+
|
|
11
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
12
|
+
integer_field :channel_id
|
|
13
|
+
string_field :user_id, nullable: false, length: 128
|
|
14
|
+
text_field :body
|
|
15
|
+
integer_field :thread_id
|
|
16
|
+
datetime_field :created_at
|
|
17
|
+
datetime_field :edited_at
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# S3-compatible store (AWS S3, MinIO, ...). Opt-in; needs the aws-sdk-s3 gem.
|
|
6
|
+
#
|
|
7
|
+
# Presigned GET URLs let clients fetch large blobs straight from object
|
|
8
|
+
# storage instead of streaming through the app.
|
|
9
|
+
class S3Storage < StorageBackend
|
|
10
|
+
def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
|
|
11
|
+
super()
|
|
12
|
+
require "aws-sdk-s3" # optional dependency, loaded lazily
|
|
13
|
+
|
|
14
|
+
@bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
|
|
15
|
+
raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
|
|
16
|
+
|
|
17
|
+
opts = {
|
|
18
|
+
access_key_id: key || ENV["TINA4_STORAGE_KEY"],
|
|
19
|
+
secret_access_key: secret || ENV["TINA4_STORAGE_SECRET"],
|
|
20
|
+
region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1",
|
|
21
|
+
force_path_style: true
|
|
22
|
+
}
|
|
23
|
+
ep = endpoint || ENV["TINA4_STORAGE_URL"]
|
|
24
|
+
opts[:endpoint] = ep if ep && !ep.empty?
|
|
25
|
+
@client = Aws::S3::Client.new(**opts)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def put(key, data, mime = "application/octet-stream")
|
|
29
|
+
@client.put_object(bucket: @bucket, key: key, body: data, content_type: mime)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(key)
|
|
34
|
+
@client.get_object(bucket: @bucket, key: key).body.read
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def url(key, ttl = 3600)
|
|
40
|
+
Aws::S3::Presigner.new(client: @client)
|
|
41
|
+
.presigned_url(:get_object, bucket: @bucket, key: key, expires_in: ttl)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(key)
|
|
45
|
+
@client.delete_object(bucket: @bucket, key: key)
|
|
46
|
+
nil
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
Tina4::Log.error("S3Storage delete failed for #{key}: #{e.message}")
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def exists?(key)
|
|
53
|
+
@client.head_object(bucket: @bucket, key: key)
|
|
54
|
+
true
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module Realtime
|
|
7
|
+
# Storage backend selection + key generation for the realtime "files"
|
|
8
|
+
# feature. Mirrors the cache/session/queue backend pattern:
|
|
9
|
+
# TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with
|
|
10
|
+
# a graceful fallback to local if an s3 backend cannot be built (missing gem
|
|
11
|
+
# or incomplete config) - a real persistent store, never a silent no-op.
|
|
12
|
+
module Storage
|
|
13
|
+
UNSAFE = /[^A-Za-z0-9]/
|
|
14
|
+
|
|
15
|
+
# Generate an opaque, collision-free storage key, preserving the extension.
|
|
16
|
+
# The key carries no user-controlled path segment, so it can never traverse
|
|
17
|
+
# outside the storage root.
|
|
18
|
+
def self.key(filename = "")
|
|
19
|
+
ext = ""
|
|
20
|
+
if filename && filename.include?(".")
|
|
21
|
+
raw = filename.rpartition(".").last
|
|
22
|
+
clean = raw.gsub(UNSAFE, "")[0, 12]
|
|
23
|
+
ext = ".#{clean}" unless clean.nil? || clean.empty?
|
|
24
|
+
end
|
|
25
|
+
"#{SecureRandom.hex(16)}#{ext}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolve the storage backend from an explicit instance or the environment.
|
|
29
|
+
def self.select(storage = nil)
|
|
30
|
+
return storage unless storage.nil?
|
|
31
|
+
|
|
32
|
+
name = (ENV["TINA4_STORAGE_BACKEND"] || "local").downcase
|
|
33
|
+
if name == "s3"
|
|
34
|
+
begin
|
|
35
|
+
return S3Storage.new
|
|
36
|
+
# LoadError (missing aws-sdk-s3 gem) is a ScriptError, NOT a
|
|
37
|
+
# StandardError, so it must be rescued explicitly or the "graceful
|
|
38
|
+
# fallback when the driver is absent" contract breaks (the whole mount
|
|
39
|
+
# would crash instead of degrading to local).
|
|
40
|
+
rescue StandardError, LoadError => e
|
|
41
|
+
Tina4::Log.warning(
|
|
42
|
+
"realtime files: S3 storage unavailable (#{e.message}); " \
|
|
43
|
+
"falling back to local filesystem storage."
|
|
44
|
+
)
|
|
45
|
+
return LocalStorage.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
LocalStorage.new
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# Blob store interface for the realtime "files" feature.
|
|
6
|
+
#
|
|
7
|
+
# put writes bytes, get reads them back, url returns a directly fetchable
|
|
8
|
+
# URL when the backend supports one (else nil - serve via the app download
|
|
9
|
+
# route), delete removes, exists? checks presence.
|
|
10
|
+
class StorageBackend
|
|
11
|
+
def put(_key, _data, _mime = "application/octet-stream")
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get(_key)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def url(_key, _ttl = 3600)
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(_key)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exists?(_key)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Realtime
|
|
5
|
+
# Workspace - the top-level container for channels (a "team" / "org").
|
|
6
|
+
# Framework-owned table: the tina4_rt_ prefix keeps it clear of an app's own
|
|
7
|
+
# domain tables (mirrors tina4_migration / tina4_sequences + the Python
|
|
8
|
+
# master's tina4_rt_* tables). Ruby is snake_case end to end, so the columns,
|
|
9
|
+
# attributes, and JSON keys are all snake_case with no mapping layer.
|
|
10
|
+
class Workspace < Tina4::ORM
|
|
11
|
+
table_name "tina4_rt_workspaces"
|
|
12
|
+
|
|
13
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
14
|
+
string_field :name, nullable: false, length: 200
|
|
15
|
+
datetime_field :created_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
require_relative "realtime/workspace"
|
|
9
|
+
require_relative "realtime/channel"
|
|
10
|
+
require_relative "realtime/channel_member"
|
|
11
|
+
require_relative "realtime/message"
|
|
12
|
+
require_relative "realtime/attachment"
|
|
13
|
+
require_relative "realtime/storage_backend"
|
|
14
|
+
require_relative "realtime/local_storage"
|
|
15
|
+
require_relative "realtime/s3_storage"
|
|
16
|
+
require_relative "realtime/storage"
|
|
17
|
+
|
|
18
|
+
module Tina4
|
|
19
|
+
# Real-time collaboration mount for Tina4 (Ruby), parity with the Python
|
|
20
|
+
# master's tina4_python.realtime. A zero-dependency control plane for building
|
|
21
|
+
# Slack/Teams-class tools:
|
|
22
|
+
#
|
|
23
|
+
# - calls: a WebRTC signalling relay + self-describing ICE-config endpoint.
|
|
24
|
+
# Media is peer-to-peer (mesh); Tina4 carries no media, it only relays the
|
|
25
|
+
# offer/answer/ICE handshake. An SFU drops in later with no route changes.
|
|
26
|
+
# - chat: persistent channels + messages (framework-owned ORM models), a
|
|
27
|
+
# secured chat WebSocket with live presence / typing / read receipts, and a
|
|
28
|
+
# history endpoint for catch-up-on-reconnect.
|
|
29
|
+
# - files: uploads/downloads through a pluggable StorageBackend.
|
|
30
|
+
#
|
|
31
|
+
# Paths are convention defaults, overridable via +prefix+, and the client
|
|
32
|
+
# discovers the resolved paths from the config endpoint so client and server
|
|
33
|
+
# never drift.
|
|
34
|
+
#
|
|
35
|
+
# Tina4::Realtime.mount # calls only
|
|
36
|
+
# Tina4::Realtime.mount(features: %w[calls chat]) # add persistent chat
|
|
37
|
+
# Tina4::Realtime.mount(prefix: "/api/collab", features: %w[calls chat files])
|
|
38
|
+
#
|
|
39
|
+
# Env: TINA4_RTC_BACKEND (default mesh), TINA4_RTC_STUN_URLS,
|
|
40
|
+
# TINA4_RTC_TURN_URL + TINA4_RTC_TURN_SECRET (ephemeral coturn creds),
|
|
41
|
+
# TINA4_RTC_TURN_TTL, plus the TINA4_STORAGE_* set for files.
|
|
42
|
+
module Realtime
|
|
43
|
+
DEFAULT_STUN = "stun:stun.l.google.com:19302"
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Build the ICE server list from the environment. Always includes STUN. Adds
|
|
48
|
+
# a TURN entry with time-limited credentials (coturn use-auth-secret scheme:
|
|
49
|
+
# username = <expiry-epoch>, credential = base64(HMAC-SHA1(secret, username)))
|
|
50
|
+
# when both TINA4_RTC_TURN_URL and TINA4_RTC_TURN_SECRET are set.
|
|
51
|
+
def ice_servers
|
|
52
|
+
stun = ENV["TINA4_RTC_STUN_URLS"] || DEFAULT_STUN
|
|
53
|
+
servers = [{ "urls" => split_urls(stun) }]
|
|
54
|
+
|
|
55
|
+
turn_url = ENV["TINA4_RTC_TURN_URL"]
|
|
56
|
+
secret = ENV["TINA4_RTC_TURN_SECRET"]
|
|
57
|
+
if turn_url && !turn_url.empty? && secret && !secret.empty?
|
|
58
|
+
ttl = (ENV["TINA4_RTC_TURN_TTL"] || "3600").to_i
|
|
59
|
+
username = (Time.now.to_i + ttl).to_s
|
|
60
|
+
credential = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
|
|
61
|
+
servers << { "urls" => split_urls(turn_url), "username" => username, "credential" => credential }
|
|
62
|
+
end
|
|
63
|
+
servers
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mount the realtime surface and return the resolved path map (also served
|
|
67
|
+
# from the config endpoint so the client can discover it).
|
|
68
|
+
#
|
|
69
|
+
# @param prefix [String] mount the whole surface under this path
|
|
70
|
+
# @param authorize [Proc] membership guard authorize.(identity, channel_id)
|
|
71
|
+
# for chat/files; defaults to a ChannelMember check
|
|
72
|
+
# @param storage [StorageBackend] for the files feature
|
|
73
|
+
# @param features [Array<String>] any of "calls", "chat", "files"
|
|
74
|
+
def mount(prefix: "", authorize: nil, storage: nil, features: nil)
|
|
75
|
+
features = features || ["calls"]
|
|
76
|
+
p = prefix.to_s.gsub(%r{\A/+|/+\z}, "")
|
|
77
|
+
p = p.empty? ? "" : "/#{p}"
|
|
78
|
+
@authorize = authorize
|
|
79
|
+
@storage = storage
|
|
80
|
+
|
|
81
|
+
# Chat and files both persist through the framework-owned chat models.
|
|
82
|
+
ensure_chat_tables if features.include?("chat") || features.include?("files")
|
|
83
|
+
|
|
84
|
+
paths = { "backend" => "mesh" }
|
|
85
|
+
if features.include?("calls")
|
|
86
|
+
paths["config"] = "#{p}/api/rtc/config"
|
|
87
|
+
paths["signalling"] = "#{p}/ws/rtc"
|
|
88
|
+
end
|
|
89
|
+
if features.include?("chat")
|
|
90
|
+
paths["config"] ||= "#{p}/api/rtc/config"
|
|
91
|
+
paths["chat"] = "#{p}/ws/chat"
|
|
92
|
+
paths["messages"] = "#{p}/api/channels"
|
|
93
|
+
end
|
|
94
|
+
if features.include?("files")
|
|
95
|
+
paths["config"] ||= "#{p}/api/rtc/config"
|
|
96
|
+
paths["files"] = "#{p}/api/files"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
register_config(paths, features) if paths.key?("config")
|
|
100
|
+
register_calls(paths) if features.include?("calls")
|
|
101
|
+
register_chat(paths) if features.include?("chat")
|
|
102
|
+
register_files(paths, storage) if features.include?("files")
|
|
103
|
+
|
|
104
|
+
paths
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ----- route registration -------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def register_config(paths, features)
|
|
110
|
+
Tina4::Router.get(paths["config"]) do |_request, response|
|
|
111
|
+
body = { "backend" => "mesh" }
|
|
112
|
+
if features.include?("calls")
|
|
113
|
+
body["iceServers"] = Tina4::Realtime.ice_servers
|
|
114
|
+
body["signalling"] = "#{paths['signalling']}/{room}"
|
|
115
|
+
end
|
|
116
|
+
if features.include?("chat")
|
|
117
|
+
body["chat"] = "#{paths['chat']}/{channel}"
|
|
118
|
+
body["messages"] = "#{paths['messages']}/{id}/messages"
|
|
119
|
+
end
|
|
120
|
+
body["files"] = paths["files"] if features.include?("files")
|
|
121
|
+
response.json(body, Tina4::HTTP_OK)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def register_calls(paths)
|
|
126
|
+
# WebRTC signalling relay (mesh). Tina4 never parses the SDP; peers filter
|
|
127
|
+
# by `to`. The signalling room is public - the media is E2E between peers.
|
|
128
|
+
Tina4::Router.websocket("#{paths['signalling']}/{room}") do |connection, event, data|
|
|
129
|
+
room = connection.params[:room].to_s
|
|
130
|
+
next if room.empty?
|
|
131
|
+
|
|
132
|
+
key = "rtc:#{room}"
|
|
133
|
+
case event
|
|
134
|
+
when :open
|
|
135
|
+
connection.join_room(key)
|
|
136
|
+
when :message
|
|
137
|
+
connection.broadcast_to_room(key, data, exclude_self: true)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def register_chat(paths)
|
|
143
|
+
Tina4::Router.websocket("#{paths['chat']}/{channel}") { |c, e, d| chat_handler(c, e, d) }.secure
|
|
144
|
+
|
|
145
|
+
Tina4::Router.get("#{paths['messages']}/{id}/messages") do |request, response|
|
|
146
|
+
identity = Tina4::Realtime.identity(request.user)
|
|
147
|
+
channel_id = request.params[:id].to_i
|
|
148
|
+
if channel_id <= 0
|
|
149
|
+
response.json({ "error" => "invalid channel id" }, Tina4::HTTP_BAD_REQUEST)
|
|
150
|
+
elsif !Tina4::Realtime.authorized?(identity, channel_id)
|
|
151
|
+
response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
|
|
152
|
+
else
|
|
153
|
+
response.json(Tina4::Realtime.history(channel_id, request.query || {}), Tina4::HTTP_OK)
|
|
154
|
+
end
|
|
155
|
+
end.secure
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def register_files(paths, storage)
|
|
159
|
+
store = Tina4::Realtime::Storage.select(storage)
|
|
160
|
+
|
|
161
|
+
# POST is auth-required by default (mirrors GET being public).
|
|
162
|
+
Tina4::Router.post(paths["files"]) do |request, response|
|
|
163
|
+
identity = Tina4::Realtime.identity(request.user)
|
|
164
|
+
channel_id = Tina4::Realtime.form_value(request, "channel_id").to_i
|
|
165
|
+
if channel_id <= 0
|
|
166
|
+
response.json({ "error" => "channel_id is required" }, Tina4::HTTP_BAD_REQUEST)
|
|
167
|
+
elsif !Tina4::Realtime.authorized?(identity, channel_id)
|
|
168
|
+
response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
|
|
169
|
+
else
|
|
170
|
+
upload = request.files && request.files["file"]
|
|
171
|
+
if upload.nil?
|
|
172
|
+
response.json({ "error" => "no file uploaded (field 'file')" }, Tina4::HTTP_BAD_REQUEST)
|
|
173
|
+
else
|
|
174
|
+
saved = Tina4::Realtime.store_upload(store, channel_id, upload)
|
|
175
|
+
saved["url"] = store.url(saved["key"]) || "#{paths['files']}/#{saved['key']}"
|
|
176
|
+
response.json(saved, Tina4::HTTP_CREATED)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
Tina4::Router.get("#{paths['files']}/{key}") do |request, response|
|
|
182
|
+
identity = Tina4::Realtime.identity(request.user)
|
|
183
|
+
key = request.params[:key].to_s
|
|
184
|
+
att = Attachment.where("storage_key = ?", [key]).first
|
|
185
|
+
if att.nil?
|
|
186
|
+
response.json({ "error" => "not found" }, Tina4::HTTP_NOT_FOUND)
|
|
187
|
+
elsif !Tina4::Realtime.authorized?(identity, att.channel_id.to_i)
|
|
188
|
+
response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
|
|
189
|
+
else
|
|
190
|
+
direct = store.url(key)
|
|
191
|
+
if direct
|
|
192
|
+
response.redirect(direct, 302)
|
|
193
|
+
else
|
|
194
|
+
data = store.get(key)
|
|
195
|
+
if data.nil?
|
|
196
|
+
response.json({ "error" => "not found" }, Tina4::HTTP_NOT_FOUND)
|
|
197
|
+
else
|
|
198
|
+
response.header("Content-Disposition", "inline; filename=\"#{att.filename || key}\"")
|
|
199
|
+
response.call(data, Tina4::HTTP_OK, att.mime || "application/octet-stream")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end.secure
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ----- WebSocket chat handler ---------------------------------------------
|
|
207
|
+
|
|
208
|
+
# Secured chat WebSocket. Ruby fires (connection, event, data); event is a
|
|
209
|
+
# Symbol (:open / :message / :close).
|
|
210
|
+
def chat_handler(connection, event, data)
|
|
211
|
+
raw = connection.params[:channel].to_s
|
|
212
|
+
return unless raw.match?(/\A\d+\z/) # framework channels are addressed by integer id
|
|
213
|
+
|
|
214
|
+
channel_id = raw.to_i
|
|
215
|
+
identity = identity(connection.auth)
|
|
216
|
+
key = "chat:#{channel_id}"
|
|
217
|
+
|
|
218
|
+
case event
|
|
219
|
+
when :open
|
|
220
|
+
unless authorized?(identity, channel_id)
|
|
221
|
+
connection.send_json({ "type" => "error", "error" => "not a member of this channel" })
|
|
222
|
+
connection.close
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
connection.join_room(key)
|
|
226
|
+
connection.send_json({ "type" => "presence", "event" => "roster", "users" => roster(key) })
|
|
227
|
+
connection.broadcast_to_room(
|
|
228
|
+
key, { "type" => "presence", "event" => "join", "user_id" => identity }.to_json, exclude_self: true
|
|
229
|
+
)
|
|
230
|
+
when :close
|
|
231
|
+
connection.broadcast_to_room(
|
|
232
|
+
key, { "type" => "presence", "event" => "leave", "user_id" => identity }.to_json, exclude_self: true
|
|
233
|
+
)
|
|
234
|
+
when :message
|
|
235
|
+
# Re-check membership on every inbound frame: it can be revoked
|
|
236
|
+
# mid-session, and we never trust a payload's identity.
|
|
237
|
+
return unless authorized?(identity, channel_id)
|
|
238
|
+
|
|
239
|
+
payload = parse_json(data)
|
|
240
|
+
return unless payload.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
kind = payload["type"] || "message"
|
|
243
|
+
case kind
|
|
244
|
+
when "typing"
|
|
245
|
+
connection.broadcast_to_room(
|
|
246
|
+
key, { "type" => "typing", "user_id" => identity }.to_json, exclude_self: true
|
|
247
|
+
)
|
|
248
|
+
when "read"
|
|
249
|
+
mark_read(channel_id, identity)
|
|
250
|
+
connection.broadcast_to_room(
|
|
251
|
+
key, { "type" => "read", "user_id" => identity, "at" => now_iso }.to_json, exclude_self: true
|
|
252
|
+
)
|
|
253
|
+
when "message"
|
|
254
|
+
body = (payload["body"] || "").to_s.strip
|
|
255
|
+
return if body.empty?
|
|
256
|
+
|
|
257
|
+
saved = persist_message(channel_id, identity, body, payload["thread_id"])
|
|
258
|
+
# Deliver to everyone INCLUDING the sender so the sender's optimistic
|
|
259
|
+
# message is reconciled with its server id + timestamp.
|
|
260
|
+
connection.broadcast_to_room(key, { "type" => "message", "message" => saved }.to_json) if saved
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# ----- helpers ------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
# Extract a stable user identity from a verified JWT payload. Chat expects
|
|
268
|
+
# one of user_id / sub / id. Returns a string, or nil when unauthenticated.
|
|
269
|
+
def identity(auth)
|
|
270
|
+
return nil unless auth.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
%w[user_id sub id].each do |claim|
|
|
273
|
+
val = auth[claim] || auth[claim.to_sym]
|
|
274
|
+
return val.to_s unless val.nil?
|
|
275
|
+
end
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Shared membership guard for chat channels and file access.
|
|
280
|
+
def authorized?(identity, channel_id)
|
|
281
|
+
return false if identity.nil?
|
|
282
|
+
return !!@authorize.call(identity, channel_id) unless @authorize.nil?
|
|
283
|
+
|
|
284
|
+
ChannelMember.count("channel_id = ? AND user_id = ?", [channel_id, identity]).positive?
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
Tina4::Log.error("realtime chat authorize check failed: #{e.message}")
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Presence roster: the distinct authenticated identities currently in a room,
|
|
291
|
+
# as strings (sorted). Collected from the live connections on the manager.
|
|
292
|
+
def roster(key)
|
|
293
|
+
mgr = Tina4::WebSocket.current
|
|
294
|
+
return [] if mgr.nil?
|
|
295
|
+
|
|
296
|
+
seen = mgr.get_room_connections(key).map { |c| identity(c.auth) }.compact
|
|
297
|
+
seen.uniq.sort
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def form_value(request, name)
|
|
301
|
+
body = request.body
|
|
302
|
+
return body[name] if body.is_a?(Hash) && body.key?(name)
|
|
303
|
+
|
|
304
|
+
(request.query && request.query[name]) || (request.params && request.params[name.to_sym])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def parse_json(data)
|
|
308
|
+
return data if data.is_a?(Hash)
|
|
309
|
+
|
|
310
|
+
JSON.parse(data.to_s)
|
|
311
|
+
rescue JSON::ParserError, TypeError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def now_iso
|
|
316
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def persist_message(channel_id, identity, body, thread_id)
|
|
320
|
+
thread = thread_id.nil? || thread_id == "" ? nil : thread_id.to_i
|
|
321
|
+
created_at = now_iso
|
|
322
|
+
msg = Message.new(
|
|
323
|
+
channel_id: channel_id, user_id: identity.to_s, body: body,
|
|
324
|
+
thread_id: thread, created_at: created_at
|
|
325
|
+
)
|
|
326
|
+
if msg.save == false
|
|
327
|
+
Tina4::Log.error("realtime chat: failed to persist message in channel #{channel_id}")
|
|
328
|
+
return nil
|
|
329
|
+
end
|
|
330
|
+
{
|
|
331
|
+
"id" => msg.id, "channel_id" => channel_id, "user_id" => identity.to_s,
|
|
332
|
+
"body" => body, "thread_id" => thread, "created_at" => created_at
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def mark_read(channel_id, identity)
|
|
337
|
+
member = ChannelMember.where("channel_id = ? AND user_id = ?", [channel_id, identity]).first
|
|
338
|
+
return if member.nil?
|
|
339
|
+
|
|
340
|
+
member.last_read_at = now_iso
|
|
341
|
+
member.save
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
Tina4::Log.error("realtime chat: read-receipt update failed: #{e.message}")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Messages for a channel, newest-first, paged by a `before` cursor. The
|
|
347
|
+
# standard infinite-scroll-backwards shape a chat client pages with.
|
|
348
|
+
def history(channel_id, query)
|
|
349
|
+
limit = (query["limit"] || query[:limit] || 50).to_i
|
|
350
|
+
limit = 50 if limit <= 0
|
|
351
|
+
limit = [[limit, 1].max, 200].min
|
|
352
|
+
before = query["before"] || query[:before]
|
|
353
|
+
|
|
354
|
+
clause = "channel_id = ?"
|
|
355
|
+
params = [channel_id]
|
|
356
|
+
unless before.nil? || before.to_s.empty?
|
|
357
|
+
clause += " AND id < ?"
|
|
358
|
+
params << before.to_i
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
rows = Message.where(clause, params)
|
|
362
|
+
rows = rows.sort_by { |m| -(m.id || 0) }.first(limit)
|
|
363
|
+
rows.map do |m|
|
|
364
|
+
{
|
|
365
|
+
"id" => m.id, "channel_id" => m.channel_id, "user_id" => m.user_id,
|
|
366
|
+
"body" => m.body, "thread_id" => m.thread_id, "created_at" => m.created_at
|
|
367
|
+
}
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def store_upload(store, channel_id, upload)
|
|
372
|
+
filename = upload["filename"] || upload[:filename] || "file"
|
|
373
|
+
mime = upload["type"] || upload[:type] || "application/octet-stream"
|
|
374
|
+
content = upload["content"] || upload[:content] || ""
|
|
375
|
+
key = Tina4::Realtime::Storage.key(filename)
|
|
376
|
+
store.put(key, content, mime)
|
|
377
|
+
att = Attachment.new(
|
|
378
|
+
channel_id: channel_id, storage_key: key, filename: filename,
|
|
379
|
+
mime: mime, size: content.bytesize
|
|
380
|
+
)
|
|
381
|
+
att.save
|
|
382
|
+
{ "id" => att.id, "key" => key, "filename" => filename, "mime" => mime, "size" => content.bytesize }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def ensure_chat_tables
|
|
386
|
+
[Workspace, Channel, ChannelMember, Message, Attachment].each(&:create_table)
|
|
387
|
+
true
|
|
388
|
+
rescue StandardError => e
|
|
389
|
+
Tina4::Log.error(
|
|
390
|
+
"realtime chat could not create its tables (#{e.message}). Chat needs a bound " \
|
|
391
|
+
"database - call Tina4.bind_database(db) or set TINA4_DATABASE_URL before mount."
|
|
392
|
+
)
|
|
393
|
+
false
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def split_urls(csv)
|
|
397
|
+
csv.split(",").map(&:strip).reject(&:empty?)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
data/lib/tina4/request.rb
CHANGED
|
@@ -298,6 +298,30 @@ module Tina4
|
|
|
298
298
|
data
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
+
# Resolve the parsed multipart form fields (+ file entries) for this request.
|
|
302
|
+
#
|
|
303
|
+
# A live Rack server (Puma/WEBrick) does NOT parse the request body for us -
|
|
304
|
+
# it hands the app the raw `rack.input` stream. `Rack::Request#POST` parses
|
|
305
|
+
# a multipart (or urlencoded) body and caches the result into the standard
|
|
306
|
+
# `rack.request.form_hash` env key, which both #parse_body and #extract_files
|
|
307
|
+
# then read. We only invoke it when the key isn't already populated, so a
|
|
308
|
+
# spec that injects `rack.request.form_hash` directly still short-circuits
|
|
309
|
+
# here (and we never re-parse a body twice). Without this, live multipart
|
|
310
|
+
# uploads arrived empty (fields AND files) - the parse only ran in specs.
|
|
311
|
+
def multipart_form_hash
|
|
312
|
+
existing = @env["rack.request.form_hash"] rescue nil
|
|
313
|
+
return existing if existing
|
|
314
|
+
|
|
315
|
+
parsed = begin
|
|
316
|
+
require "rack"
|
|
317
|
+
Rack::Request.new(@env).POST
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
@env["rack.request.form_hash"] || parsed
|
|
323
|
+
end
|
|
324
|
+
|
|
301
325
|
def parse_body
|
|
302
326
|
if @content_type.include?("application/json")
|
|
303
327
|
json_body
|
|
@@ -307,7 +331,7 @@ module Tina4
|
|
|
307
331
|
# Extract form fields from Rack's parsed multipart data.
|
|
308
332
|
# Files are handled separately by extract_files.
|
|
309
333
|
result = {}
|
|
310
|
-
form_hash =
|
|
334
|
+
form_hash = multipart_form_hash
|
|
311
335
|
if form_hash
|
|
312
336
|
form_hash.each do |key, value|
|
|
313
337
|
# Skip file entries (handled by extract_files)
|
|
@@ -349,7 +373,7 @@ module Tina4
|
|
|
349
373
|
result = {}
|
|
350
374
|
return result unless @content_type.include?("multipart/form-data")
|
|
351
375
|
begin
|
|
352
|
-
form_hash =
|
|
376
|
+
form_hash = multipart_form_hash
|
|
353
377
|
if form_hash
|
|
354
378
|
form_hash.each do |key, value|
|
|
355
379
|
if value.is_a?(Hash) && value[:tempfile]
|
data/lib/tina4/test_client.rb
CHANGED
|
@@ -122,6 +122,16 @@ module Tina4
|
|
|
122
122
|
|
|
123
123
|
route, path_params = result
|
|
124
124
|
|
|
125
|
+
# Route through the REAL auth gate (parity with the live server). A write
|
|
126
|
+
# to an auth-required route (POST/PUT/PATCH/DELETE by default, or any
|
|
127
|
+
# secured route) with no valid token / API key / formToken 401s here
|
|
128
|
+
# exactly as it would in production. The TestClient used to skip this and
|
|
129
|
+
# run the handler directly, so a green test could hide a live 401 — the
|
|
130
|
+
# verification layer lied. A public route (GET, or a write marked
|
|
131
|
+
# .no_auth) has auth_required false, so this is a no-op. (#PY2 parity)
|
|
132
|
+
unauthorized = Tina4::RackApp.enforce_route_auth(env, route)
|
|
133
|
+
return TestResponse.new(unauthorized) if unauthorized
|
|
134
|
+
|
|
125
135
|
# Create request and response
|
|
126
136
|
req = Tina4::Request.new(env, path_params || {})
|
|
127
137
|
res = Tina4::Response.new
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -7,7 +7,13 @@ require "set"
|
|
|
7
7
|
require "securerandom"
|
|
8
8
|
|
|
9
9
|
module Tina4
|
|
10
|
-
|
|
10
|
+
# RFC 6455 section 1.3 magic value. MUST be exactly this string - the client
|
|
11
|
+
# concatenates it to its Sec-WebSocket-Key, SHA-1s, base64s, and compares to
|
|
12
|
+
# our Sec-WebSocket-Accept. A wrong GUID yields a wrong Accept: raw clients
|
|
13
|
+
# that skip the check still connect, but a browser validates it per spec and
|
|
14
|
+
# refuses the upgrade (silent failure). Verified against the RFC test vector:
|
|
15
|
+
# key "dGhlIHNhbXBsZSBub25jZQ==" -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=".
|
|
16
|
+
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
11
17
|
|
|
12
18
|
# Shared pub/sub channel name + envelope shape for the WebSocket backplane.
|
|
13
19
|
# MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node)
|
data/lib/tina4.rb
CHANGED
|
@@ -52,6 +52,7 @@ require_relative "tina4/test"
|
|
|
52
52
|
require_relative "tina4/docs"
|
|
53
53
|
require_relative "tina4/docstore"
|
|
54
54
|
require_relative "tina4/mcp"
|
|
55
|
+
require_relative "tina4/realtime"
|
|
55
56
|
|
|
56
57
|
module Tina4
|
|
57
58
|
# ── Lazy-loaded: database drivers ─────────────────────────────────────
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.57
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -372,6 +372,16 @@ files:
|
|
|
372
372
|
- lib/tina4/queue_backends/rabbitmq_backend.rb
|
|
373
373
|
- lib/tina4/rack_app.rb
|
|
374
374
|
- lib/tina4/rate_limiter.rb
|
|
375
|
+
- lib/tina4/realtime.rb
|
|
376
|
+
- lib/tina4/realtime/attachment.rb
|
|
377
|
+
- lib/tina4/realtime/channel.rb
|
|
378
|
+
- lib/tina4/realtime/channel_member.rb
|
|
379
|
+
- lib/tina4/realtime/local_storage.rb
|
|
380
|
+
- lib/tina4/realtime/message.rb
|
|
381
|
+
- lib/tina4/realtime/s3_storage.rb
|
|
382
|
+
- lib/tina4/realtime/storage.rb
|
|
383
|
+
- lib/tina4/realtime/storage_backend.rb
|
|
384
|
+
- lib/tina4/realtime/workspace.rb
|
|
375
385
|
- lib/tina4/request.rb
|
|
376
386
|
- lib/tina4/response.rb
|
|
377
387
|
- lib/tina4/response_cache.rb
|