From 2dad3bcac2ff7b2bc6098db1506a2789a488a97b Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 30 Sep 2026 08:02:36 +0700 Subject: [PATCH 1/7] ci: make docs workflow dispatch-only (GitHub Pages never enabled) (#124) The push-to-main trigger failed on every main push because the deploy step requires GitHub Pages, which was never enabled for this repo; docs are hosted on Cloudflare Pages (since #49). Drop the deploy job, keep the VitePress build as an on-demand check with a regular artifact. Closes #123 Co-authored-by: ajianaz --- .github/workflows/docs.yml | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fa9acdf..e242d77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,20 +1,21 @@ -name: Deploy Docs to GitHub Pages +name: Docs Build Check + +# Docs are HOSTED on Cloudflare Pages (cosy-docs-4f5.pages.dev, deployed via +# wrangler from a machine with CF credentials — see docs deploy guide). +# GitHub Pages was never enabled for this repo, so the old push-triggered +# deploy workflow failed on every main push (issue #123). +# +# This workflow now runs on demand only (workflow_dispatch) and just verifies +# the VitePress build succeeds, uploading the built site as a regular artifact. on: - push: - branches: [main] - paths: - - 'docs/**' - - '.github/workflows/docs.yml' workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: - group: pages + group: docs-build-check cancel-in-progress: false jobs: @@ -35,16 +36,9 @@ jobs: echo "::error::Build output is empty or missing." exit 1 fi - - uses: actions/upload-pages-artifact@v3 + - name: Upload build artifact + uses: actions/upload-artifact@v4 with: + name: docs-dist path: docs/.vitepress/dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + retention-days: 7 From cda54241a4ae6351b22aeed4ea5b41b435443116 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 30 Sep 2026 09:12:31 +0700 Subject: [PATCH 2/7] docs(brand): add cosy logo symbol variants (3-tone, mono, small-cut, PNG set) (#125) Co-authored-by: ajianaz --- docs/public/logo/README.md | 28 ++++++++++++++++++ docs/public/logo/logo-symbol-black.svg | 1 + docs/public/logo/logo-symbol-mono-e8590c.svg | 1 + docs/public/logo/logo-symbol-small.svg | 1 + docs/public/logo/logo-symbol-white.svg | 1 + docs/public/logo/logo-symbol.svg | 1 + docs/public/logo/png/logo-symbol-128.png | Bin 0 -> 4674 bytes docs/public/logo/png/logo-symbol-16.png | Bin 0 -> 527 bytes docs/public/logo/png/logo-symbol-256.png | Bin 0 -> 9482 bytes docs/public/logo/png/logo-symbol-32.png | Bin 0 -> 1123 bytes docs/public/logo/png/logo-symbol-48.png | Bin 0 -> 1716 bytes docs/public/logo/png/logo-symbol-512.png | Bin 0 -> 19770 bytes docs/public/logo/png/logo-symbol-64.png | Bin 0 -> 2374 bytes docs/public/logo/png/logo-symbol-small-16.png | Bin 0 -> 553 bytes docs/public/logo/png/logo-symbol-small-32.png | Bin 0 -> 1121 bytes docs/public/logo/png/logo-symbol-small-48.png | Bin 0 -> 1634 bytes 16 files changed, 33 insertions(+) create mode 100644 docs/public/logo/README.md create mode 100644 docs/public/logo/logo-symbol-black.svg create mode 100644 docs/public/logo/logo-symbol-mono-e8590c.svg create mode 100644 docs/public/logo/logo-symbol-small.svg create mode 100644 docs/public/logo/logo-symbol-white.svg create mode 100644 docs/public/logo/logo-symbol.svg create mode 100644 docs/public/logo/png/logo-symbol-128.png create mode 100644 docs/public/logo/png/logo-symbol-16.png create mode 100644 docs/public/logo/png/logo-symbol-256.png create mode 100644 docs/public/logo/png/logo-symbol-32.png create mode 100644 docs/public/logo/png/logo-symbol-48.png create mode 100644 docs/public/logo/png/logo-symbol-512.png create mode 100644 docs/public/logo/png/logo-symbol-64.png create mode 100644 docs/public/logo/png/logo-symbol-small-16.png create mode 100644 docs/public/logo/png/logo-symbol-small-32.png create mode 100644 docs/public/logo/png/logo-symbol-small-48.png diff --git a/docs/public/logo/README.md b/docs/public/logo/README.md new file mode 100644 index 0000000..d8ad453 --- /dev/null +++ b/docs/public/logo/README.md @@ -0,0 +1,28 @@ +# Cosy Logo — Symbol Assets + +Fan mark: tiga kartu identik (tilt -8°) — back outline Ocean Blue, mid outline Cyan, front solid Brand Orange. +Satu sumber, banyak output. Logo only — wordmark/lockup tidak termasuk set ini. + +## Warna +| Role | HEX | Kontras vs putih | +|---|---|---| +| Ocean Blue (back outline) | `#1864AB` | 6.09:1 | +| Cyan (mid outline) | `#22B8CF` | 2.38:1 | +| Brand Orange (front solid) | `#E8590C` | 3.58:1 | + +## File +| File | Pakai untuk | +|---|---| +| `logo-symbol.svg` | Master — ukuran ≥32px | +| `logo-symbol-small.svg` | Favicon/app-icon ≤32px (3-tone filled, pemisah putih) | +| `logo-symbol-black.svg` | One-colour hitam (print/fax/grayscale dokumen) | +| `logo-symbol-white.svg` | Di atas background gelap | +| `logo-symbol-mono-e8590c.svg` | One-colour brand (watermark, tekstur) | +| `png/logo-symbol-{16..512}.png` | PNG siap pakai per ukuran | +| `png/logo-symbol-small-{16,32,48}.png` | PNG small-cut | + +## Aturan cepat +- Clear space = tinggi satu kartu di sekeliling mark. +- Urutan tone tidak boleh ditukar: terang di belakang → padat di depan. +- Konteks one-colour/greyscale → pakai orange solid (`-mono-e8590c` / `-black`), bukan 3-tone. +- Jangan: ubah tilt, tambah kartu ke-4, gradient, drop shadow. diff --git a/docs/public/logo/logo-symbol-black.svg b/docs/public/logo/logo-symbol-black.svg new file mode 100644 index 0000000..7289d8e --- /dev/null +++ b/docs/public/logo/logo-symbol-black.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-mono-e8590c.svg b/docs/public/logo/logo-symbol-mono-e8590c.svg new file mode 100644 index 0000000..dd05fae --- /dev/null +++ b/docs/public/logo/logo-symbol-mono-e8590c.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-small.svg b/docs/public/logo/logo-symbol-small.svg new file mode 100644 index 0000000..a21091c --- /dev/null +++ b/docs/public/logo/logo-symbol-small.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-white.svg b/docs/public/logo/logo-symbol-white.svg new file mode 100644 index 0000000..9e6529c --- /dev/null +++ b/docs/public/logo/logo-symbol-white.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol.svg b/docs/public/logo/logo-symbol.svg new file mode 100644 index 0000000..165b333 --- /dev/null +++ b/docs/public/logo/logo-symbol.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/png/logo-symbol-128.png b/docs/public/logo/png/logo-symbol-128.png new file mode 100644 index 0000000000000000000000000000000000000000..3419aa63b144aacb7e4dadcb7bf82b06f8ff3f70 GIT binary patch literal 4674 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 R@|^$x002ovPDHLkV1h(}_;vsQ literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-256.png b/docs/public/logo/png/logo-symbol-256.png new file mode 100644 index 0000000000000000000000000000000000000000..06b5abc18750e6673afc8e4ed3adc253dd1d9815 GIT binary patch literal 9482 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 literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-32.png b/docs/public/logo/png/logo-symbol-32.png new file mode 100644 index 0000000000000000000000000000000000000000..e9fe0bc43264b31c1fdf3a0a003d2cb656871924 GIT binary patch literal 1123 zcmV-p1f2VcP)n($VMQ7PF`yf9 zfHtHGQ3A$|in=gHB_=EgnrH$Dh!BjFHb`6;N?2$R(deR$+8E%?herVmHsfI*;>2-6yqh&?keE?7?U)M6t z_B(!YG@xhzB_N`HRS5t-0EI45Hb-vgR$5jh#|==?_*F^Lll%Y(0wu%j(F=If;~kOR z!42aAC|lE9iX+E?S!qi_E+Ezwm5>UqAZ{{gTuJn}m;K@1vWs)pqkIAw#4L#HTK>yO z0dfX7j(G!g7VwuvU&`-~eSdcEns|0dW%EgIev!`)c5yzB1_TY*>xrm%q|A78f2djB z78a2Yr+KdL`L3?6e{=xwG=PY_9^Dl@mN9FNv@I3G3#u#xDpajNri6l-OAP;1XG1N!v3l~p0aP!2F09GZGu52Jhq&HAO(5cyRk%oe8qIP|T z2849-!8F0bN5K4SbHGPvQde*XK&*rSRMJ4erIq<}MOi;owtGwGMT4aULjoim&ZLKB zr7t%Z@Gl?$$C296CkH^(-127|0{}XAJ{tjIfJi*Yuhygh1V8U-D&Cr#V*~&rfxD*n zf)r5~K%C_4zF3b7D2g|*;MZ~hb*wvZ-Nd;pJfYZMnxC+e6F@2}CyUzm$ zqVksPB#|-3)fNBUP`E`@Hx3ha71Ra!%?kU=zw9}l{c>CY3KFUJH5z2C1!GiQMM4-{ pYpv^@;Bkxhc6E&>#{>QM^$$%ih!%Obw9o(m002ovPDHLkV1jQu0NMZm literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-48.png b/docs/public/logo/png/logo-symbol-48.png new file mode 100644 index 0000000000000000000000000000000000000000..737918c53efdfcb1f80072ff9d1661bd546f9a58 GIT binary patch literal 1716 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 YQcQxOkx`Q_|7SmFuOq+LdW2s2FL7cf8UO$Q literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-64.png b/docs/public/logo/png/logo-symbol-64.png new file mode 100644 index 0000000000000000000000000000000000000000..2e66d7dd87b165c3b679698bc82309cea668fee0 GIT binary patch literal 2374 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 literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-small-32.png b/docs/public/logo/png/logo-symbol-small-32.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd4a339f19c138ffa262fc5b2fe9c4530ad1efe GIT binary patch literal 1121 zcmV-n1fKheP)m?C}<-Pa94oYUzT8(x^;6j9j+!7nlp1sQ%=ncI@yHoK&*rF2bdllOg( zACk1yWliITAnXq>oab`R`JKb_JTKhP4gH@L+k9k6^XAW6t|f+4+Yo*B<2v<)6UKN6 zMHA}LEtV!wD*@U8h{CUyCtEKbe&Uzw1`vJWi|57Cmr-tBWUMjAvTnI_u=nCM1GH{E z_OvRWR&%y3p`)>mSfq}4B+Ns1w4d0pHj)O;0sBRyDvVumR(a{4Q$fGs!Q0xnJJCoy z5+)w4r(;D3+sxIFbG;42xf!oL7!VP5s_J7R@_o$!iYy169j|F`;ONf#Xk8X$vfwh5 zo1t$y&+(yAhH^8dFV7z{1p!Rk5yv zHvW8$lM~s63Ibr#aoL~BnD=jwg@J>r`W(wK zsg6dBWufX(7{3T3r*JbVrqU^#Oh0aB05{W*iFVPj<=qBf_qt=MdZ&o=EeK#CNw0JU zCZ`JkH2ZCab47?7G^4i0>BwQ#l)V+t)~5FYzc(60Xyb$ zRgjC7EZc*lQvh^^ik%2p7SFA0Wcr6sxP0_=ob!DPCNIm&PLMx+!l2%UzZY9Uz6K`+ zcmU@wWpP}kGvo(gsm~C!Z3@5rST*Stg{AvZ)m2piF1-EZG%y0-c*=$G+-wK(o(F)0 zm)8z3I)GTFPE{id0+e*NYz&NErZZRsm?*eR<@1HIyfg#T1*CP=)m*PCcXSBUQx?^M z-MhW?p^cjr>)sV%|9b$j(m5E)&ERY50OPBXpnx|%;ALBWG>Y?O8McfQXD)A_q3z)WmB&ZMa%|r0mv270)Z>Ots_TsTZ@piuIu@O= z+gG!6#}PHrE(5Y}cBz&E=1Wyy1@>gWev9l^dr>EkY3#tXbYr4j*ezX{Xcwlf$F-X~ z1>0|->LBuph`e(>00cMyyr^#Rhb6{07f-!EQ7?sMp8-<9Y2X_X8Ji#HUjxhoxd(W- n6l)Mj0!a}Wxz^Y>^q=T2@m+kk(^7Wd00000NkvXXu0mjf|4R=M literal 0 HcmV?d00001 diff --git a/docs/public/logo/png/logo-symbol-small-48.png b/docs/public/logo/png/logo-symbol-small-48.png new file mode 100644 index 0000000000000000000000000000000000000000..6ba1993081a2ab854dd129e905969a5672f9449a GIT binary patch literal 1634 zcmV-o2A%ndP)`e`jX)y>VhY-d%U?c>Rb{Xh{PRQkx1y10@wi zd}$k6B&q~bq)?-EkYKJ3G@FYm+l7w}(N&D?wL-1GnMJ?GrJv#^bAY-1bS_c`Pe%76{ zbf@FDXXn0m?9Hw4v0(v`lTSn~!`ka8BkIVozzD*K!id1G!mu35(>c@l%>0?Vr#I@; zjiv?k96uK`EqT8nZ&9>Ja0Cdq;BFFY_1&pQ@1JO$YHe7}42qsS_pl&e1bm9-skG%z z`?1Z(fWvJ)UIPsUL?1kNT2#Ka8O|a*{R~FJcYNu}!o#;DIu*zQe-x4P?asOG7kR@c zo(VddnMUboYBGV|&LD%45Q&~JiAb2iNVsqxBIK+4eO&-fh{)es9(Z}2Umn^=&@|$} z$ACvRML@=K{^fJ36+Cp;K89mE$k+}a|MO*DT4-;NG6Z5iFLA$zzb~eFXJ-iDbEKWZ zdP{Au(U8Jf;8P;9Qp?!HKA>YDB>^1)5Am)b058lg)+K#!yo-C{UEJIe#M6kRV`IO5 z6?^6~cIq;^n!$VX9a#Nu1vm-xs_KVC#HoC=p#Vk31a7VAm+CkVTPA1UF;onPHQ1}z z^OM-AadHKs_RM(xI+sLq+4AusgpQsjeBYNra+ zD*KXi`J^2P`pWum>k9MUp8U3P{n1k_o_UCsi@(C2xzZYW7nXlLTD;w>s ziPnQ=)7WoJ6`LcK_J#tc&U|EE;hMWIIkyBRwN#p$#6-fpROA2|^JJV2tN zlui`$VCxdOtP-^(popuZ#}~BUA`PUSi1WlKU zV9?#))Qhcp5Ru=g>PLam{(v{|)tma)Y`!2yXD`meRQ(BBfM@VP@%A-$|3*Rx01LP+n?05uzL3r zI(nMUFFcEw9)MSX$IE=T@1Z_a^+Dhas{H4NpJn;^@7EkBmKQUUz>E%J#zrusLs-#4 z%-AsI?taLVE+PrMPo(_!$yNz4faicC0NGc5&&q}8z%Z~PgP5@)%;+#?cf6T1(=6~4 z;431sP-)zbvz*2M4(`QP4Su50l#AyVh{AY1$o4M5=~)p6j)Lf30llf1ef g$!%<78~?-jFF!RCau_KYxc~qF07*qoM6N<$g1NFITmS$7 literal 0 HcmV?d00001 From faf89b6af9f2fa7acc6203655bd46bde22add390 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 30 Sep 2026 09:29:17 +0700 Subject: [PATCH 3/7] docs(brand): replace icon set with 3-tone fan mark (#126) Co-authored-by: ajianaz --- docs/public/logo/README.md | 4 ++++ docs/public/logo/apple-touch-icon.png | Bin 0 -> 5946 bytes docs/public/logo/favicon-16.png | Bin 0 -> 553 bytes docs/public/logo/favicon-32.png | Bin 0 -> 1121 bytes docs/public/logo/favicon-48.png | Bin 0 -> 1634 bytes docs/public/logo/favicon.ico | Bin 0 -> 3362 bytes docs/public/logo/favicon.svg | 1 + docs/public/logo/icon-192.png | Bin 0 -> 6998 bytes docs/public/logo/icon-512.png | Bin 0 -> 19770 bytes docs/public/logo/maskable-512.png | Bin 0 -> 14904 bytes 10 files changed, 5 insertions(+) create mode 100644 docs/public/logo/apple-touch-icon.png create mode 100644 docs/public/logo/favicon-16.png create mode 100644 docs/public/logo/favicon-32.png create mode 100644 docs/public/logo/favicon-48.png create mode 100644 docs/public/logo/favicon.ico create mode 100644 docs/public/logo/favicon.svg create mode 100644 docs/public/logo/icon-192.png create mode 100644 docs/public/logo/icon-512.png create mode 100644 docs/public/logo/maskable-512.png diff --git a/docs/public/logo/README.md b/docs/public/logo/README.md index d8ad453..f3285de 100644 --- a/docs/public/logo/README.md +++ b/docs/public/logo/README.md @@ -20,6 +20,10 @@ Satu sumber, banyak output. Logo only — wordmark/lockup tidak termasuk set ini | `logo-symbol-mono-e8590c.svg` | One-colour brand (watermark, tekstur) | | `png/logo-symbol-{16..512}.png` | PNG siap pakai per ukuran | | `png/logo-symbol-small-{16,32,48}.png` | PNG small-cut | +| `favicon.ico` / `favicon.svg` / `favicon-*.png` | Favicon situs (3-tone) | +| `icon-192.png` / `icon-512.png` | PWA any-mask (transparan) | +| `maskable-512.png` | PWA maskable — full-bleed warm-white, safe-zone 22% | +| `apple-touch-icon.png` | iOS home screen 180px (bg putih) | ## Aturan cepat - Clear space = tinggi satu kartu di sekeliling mark. diff --git a/docs/public/logo/apple-touch-icon.png b/docs/public/logo/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..994680b41ec7acd5c3a209b246e9e4d775bd825b GIT binary patch literal 5946 zcmcJT^;Z;J(8rgSUZi0uQA(GNrIA$W?iNIlT)IP9K)SnAL0Y7jSYYXp&ZSej>*aa> zhWGv9-h0kHGjq<|J7?y6zTs*r3WRtxcmM!^@clbkjizLtrDw0IjR2V)aa8q34X%0G>b8L@)~Sj$aoZ(SMg z9yCSQ=BIvJ&1Hcqki|msL!E+E_^{5!Ng)?Ie4_1MJDu4SDK|?TIUE!)w%|B2(tKVN zrk@g~IR~FT4+HQEy!~qr_Y>Qaa^nWwo^CN6`v)KfZI)=pxYE0_f^_ZN`RMVI5?Ltk%_P-<~9 zKW+_B{=Q8>N zr>V~%7$&`E4C#wH04dkVS%1$jr_nQ__iGV1MzwY4;TgKSd`m6`DUgRF`R(6PqTeEp z#fDf~YLO&oBEcq~;Cq=5t+nC&q`X_>&7EcEkEaikL@X=I^*b31#`U8=9a_kPI9EN7 z7iXf{7pxO`%dKCm2)_sp?BxM}yO6J0uXv`8N&6__w3Xv7nkky`~6S$Z8uCY1y+n0zHPTU!C3Dv`VVVntLd<%V6i`5I~Orvd_h~u zu#^012vu_PcD61yn?aXEiSzuB0{IHWnzDcYrgdJ@e=<~v>Y1Ofx%N?erEmd(%s6=z z;&@TAhtB|5|o1L1xIzyRZ zRQo_~fv+^(p`Q;!;2NV<*vcfV;ydXBMN905*BH5i@k%`6G9EZPbaN zNdwreC)n=oc4?~d+1~(9g-<@cD$P19lJTPpI)HJo$}vh+`Ef(6T)J50HRMmP)X*iiCy1YKFT@p30qHchr$- zID9n+mXM*KDTLV~mccSdv{^ilpaMt5N^X9J$;F=ng>EI;B$OuVX6HmolGLr3R^Gq1>>KZ zEdM7x&oT|X;H!}PY9zXcqU!fC%WL3VnB|s~#_yq519MvqjRIbD04Yo}1&&i&i<9$e zSB4?mRaTk-)f;)fmMRC6j4_OBfPFU_DFV77jHbMlg!;;AX{kyS4WrgcAbJh!mDVx= z9vY20oJmFJ20>VOy=LjGYH>{);RPphnQRZvPWyQeo;4>%I2E2R;`P>R9u=sCn z)3)y)gw*E}003S1LWe}3yw*Yr`BQ~m-LT~V|HQe^JZ0aON1;8nu?SLLES?0)onR?S z*(QJbOELHo z&%hN~=#Vu%MZm+e_Z6F~ho`)Dc1%)4yG1`WAB7<4ukzUcUPeC`4y2m0=3|hg#(ZbT z`nh_w2(pP*>09;oX>y<(B_`+05XY4VL`Zp|b*$q?)cQGEmqk*&y4C33SCZb7Y?jm| zOp=8rp&#SMLgsp~`#um5B0}vt1GYZ6IK(h1`I0fJM3P|V>n7yZa919nb#V+nM($F= z6droXBz8n!BB0F3OBOR)Rm+}gtMh10ql>59M9a~}=dAfiMPOJ`ezvxo#3$$U%Z6*3 zH4M<{k1RvS4hcWN`SD}&ApbC2_nyO1WL5CdS{L(C<-aP!Hkvb&skxOmx2oLFc%VwU z6g(L!{DzpJejfhwN%KUM0Y<>Ljv_wp1$W=iq%yckQyDa=R6rRDEb=aDXcQ1^2Z9?D z2@lj{*3gD7HUO@iPMgV#`qjcn#PZ3cEKB`RZ#|hyvVjZSj#fcB7lzS)!;;&5>`7+Z zJ`ib13?^I7>%Nq8@#5`hHm6x}O?v__Ll?x9W z9;0#|(5Xu-DV@bO%bI1~rs=;~>g}BBR8E&4Y4@&ERJ3QnxfGq=u30Rl?r&G!Us@_3 znz)C*{$Vc(Fc5jO*oyGIX9LvHK)DoXg4K7w_;2CGc^V8(kOa|L6!rf=o@hOw3o8|=@9;Kb@uO`zx8?F6pZWy#HxF6PhNwn7KHVTcogce|U`mX%CboXS%5gnHqn#E+Pw&$oJ zQ2pk`G5BvzTGA~A+5YwJD(XTz2jgoSl51`Vq-IEm=;lea`rHUt1!1H#kZ1VxN0#e5 zqxF!)a6i8Lehg$_SdqWIz6#{N?x=?MCbcJiZ^?PsP)^%oa56p(JU@Fb5-2BAo8K>h z0f3jAZHd^oeds5*bJ1Tc^lEZOe6cAXJ^ZC(Mf_Z>=v&%MuXQanF!htcw3nOjDDN$}=&M7wV zP}zS`tc|0;Lg!84d-X9!(@G%!m;`wBYGX~fs{@Cj)B8k%5t;Q_!Q}fejXtF#t+0|# zqc4&SqHs-fhdR|k{}o6;;UIp*mVc`wK?^OuML7TmTSDuv~jZ5 z&bs%Jj~_j~gHlIa##)U&5(~|ZFr-mQTjb&WSUe85*{?Vu+t<518c;}^nPeYh(cAu6@0stysO>A|4n9oTlvyvCNG%}XE1sc*}>I&Bpt^5 zW6_<7az4lZtfv#MQXx`~$(s>iD}0}jjJ0Jmp7EcpiA!8s!GuFx6r5@Vs%h}DWAnFo zO3T`ve(_V&2Fl%BHrP$-W2KSvMR#b)Mf7JaA>$XDoKHFe$Y*EMur#S=;+C7W6MpfN z!+P0ieaLeLDUexT%Ulb1<&qa4d~d2Vf^2;IWBosx-d3>O+prTge><8)e$fbj5f(`< zT$D%&OV|A1unk{l78-PTf|40y!|q`0;j|uBJr0@9CdTM0#mo0zocE`+);|S=>3LF6 z`971EmVuCnKXhoNX$7)5%(o4__<5Hfe17k)U|*IU%33?IX5V*g2nwnVcJRJr4mr#EsjM~OT`6`0gLblhHOZfSom9Q}`8dBl z)ej71ww@e?ZKM?lR03tM6R+XRYKq|+HmGQ~QE}BFpM-7a`6#0%J#ojb?0JjsRO8%bZDw^c=cVhaB%6dt^ zTIIg>UZEXf(Ndvr3d87O1vjNndj1SKxf^y(wF{!J1^?s{x7TPo|E$)WxU<#|D@+;;acm{e{)*l#9SSz3sr zUVxw6Pug2!#;l}&Bbq%Yu%Wvkxp@p+uuG$R z)paY*mM?uP_x-v7fu=3KVMwTC%Z=-VGrKZ}-?$j7Y%ecU1d*oe6YP;iA>;+6i2MYZ zBE-mf-8eZ=>U0Qo1p|q8-lM(g8#5Y5U%!qr5*TLQZAfNKk;eX zzfNJa7*ctVqin)J_JUA+@Q;lotYn_@VP+LLf3~vIanLv?CTy|eJ3 zMqF^i?)9?()Jqtpvd3(yZ?56LoSYiuqyxgUt(n>$qIwtVsGt#lNbx7BfqDtI?A0*dJ737fV+$7h>7 zVB2HAkW(}W@EG_oj<@#;9)7U0@<&Fy8hv!A}wYGZ$uTtc)&JEiFP;;kE&W%t*o&k(mAe1zEmh6q*>Xbp`?#c&) zDyoFtwYs!_kE=C5hjABLKVLTAuZU z9&+C+*(U{}7lr20rs25Dd#rum8+EQ`egts@plO7NsqK*gLTFjUBYB>c=X z^KIo5o!*E?Q&)cNpS0Fec){V~HCPCj#OR5zOh2W#9yVP69&Po|0={Wjx*85zKgPY1 z=1`~`=;kmKF;)$5xCX(Lz%`$3`3UHqwQviT^4H>%Mzx9UVNxZFit$bgi3sq<6+st( zzz2(zcA2$E@7{z@LvgKf7y!mmWH+HdSl+mT zS&dQ1jd(p(Y=uhN8q#Tk004H)>E`L-fO1LhGp7KfDWxf!!s-(&Me;Dqpn-p!adSPgy+UTEE z&^Wa)!m7Qg_WE*+r|*J*>+XfDI7KlY-8G*G+7dU5h8=#FdP_ zpVD`MIDnPr)43&ODCICp1(=U!!cHyYqie#Nm5 z{v3HPZMN*7HZ&O__Nj4P;fE3k5Xf~azNfmQMIU>^WbrSZ6lQgN{WRtzoB;rdAnUoJ+YI|{sN%G%!I*Ni=lP}-6vMZ+3)Cx@x+fF$nJe(B# zaEWyqAjQHB$n#aed@p$z#VNaYF&oLPQ7PPoJSj zTm$D|Rfd9wW3=i)rdfm*0W|eUZKSaTVb0g`lWbwp$YWp)pd0(zRg9bxx0qbmgvxA2 zEtGDAvZkdEH-A8hKQ-LF`HAT=-mqlj#oYI5Fb>b{!MQVP%@?7YUavnKn*WZ#2n}cO zi(WjAA1fHeT9{RPJW$^BVo;w9*C{BI&(vK~KS?x1rrs&iOt=5ipw8_9&TI|zc>p~)>oIwr%tl%RV%Gu9a?am;7gPYU$w<=#rdhwZ5>J!GNQ{n)8 zU!=GASqbzMSQgIzt)E$F9c=no?}chiZLAwN9lvLO`W3u9W70>rMsDqwJQHS43gRfj z=Su(-A+T*n5)(J3{j0LGXDlQdPDrP8f21jGyJ@h$vHp=RRYYZMR1Y4~eT;Tz^Vl8A z0-(M^{PKe{|8`f#RK*VNgS%nnQ<8d7_aS0G}alOwN&;>Imk9e2~q*OaV#|vvVKw zOITxvi3ACG&1V8uZe7x`8I?L$Qf0c@TU+k`l;2wYt7l37Ke}-IBTfyIXFiSy4%?F^ P4tOu8BKu3)Eckx_L;qe1 literal 0 HcmV?d00001 diff --git a/docs/public/logo/favicon-16.png b/docs/public/logo/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..d9f7107d91b568bc0d492a496ec0d64a9da5b438 GIT binary patch literal 553 zcmV+^0@nSBP)2!aZ_hz?57 zK@jSoi|9Y_x;cr9lTIRtUmy-$im2dU(1JyYEv=!UAGFdmX_EKjIE02oOT^6wF5C;} zJom@B7yfT7%fPaXP9@TMh)%pb4{}meN5L2c9RX|=y}JDP^1F_L{)t!TP$mJQdrgj| zdKr%QFdU087LQcoeR~!H$o*(n@M){Szq3(~0TDQniE<~GWoo^|T&>QBN}0m!M%XWs zesg^6fvTWfhJ^rEVc zi%7XCFfj6pN4e?{_8H{*yScIS9`B)rR<{7K8!Hk%dvi3ByLS}edk(IyI|1-Y;+;6Neibgkl00000NkvXXu0mjfiGu@n literal 0 HcmV?d00001 diff --git a/docs/public/logo/favicon-32.png b/docs/public/logo/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd4a339f19c138ffa262fc5b2fe9c4530ad1efe GIT binary patch literal 1121 zcmV-n1fKheP)m?C}<-Pa94oYUzT8(x^;6j9j+!7nlp1sQ%=ncI@yHoK&*rF2bdllOg( zACk1yWliITAnXq>oab`R`JKb_JTKhP4gH@L+k9k6^XAW6t|f+4+Yo*B<2v<)6UKN6 zMHA}LEtV!wD*@U8h{CUyCtEKbe&Uzw1`vJWi|57Cmr-tBWUMjAvTnI_u=nCM1GH{E z_OvRWR&%y3p`)>mSfq}4B+Ns1w4d0pHj)O;0sBRyDvVumR(a{4Q$fGs!Q0xnJJCoy z5+)w4r(;D3+sxIFbG;42xf!oL7!VP5s_J7R@_o$!iYy169j|F`;ONf#Xk8X$vfwh5 zo1t$y&+(yAhH^8dFV7z{1p!Rk5yv zHvW8$lM~s63Ibr#aoL~BnD=jwg@J>r`W(wK zsg6dBWufX(7{3T3r*JbVrqU^#Oh0aB05{W*iFVPj<=qBf_qt=MdZ&o=EeK#CNw0JU zCZ`JkH2ZCab47?7G^4i0>BwQ#l)V+t)~5FYzc(60Xyb$ zRgjC7EZc*lQvh^^ik%2p7SFA0Wcr6sxP0_=ob!DPCNIm&PLMx+!l2%UzZY9Uz6K`+ zcmU@wWpP}kGvo(gsm~C!Z3@5rST*Stg{AvZ)m2piF1-EZG%y0-c*=$G+-wK(o(F)0 zm)8z3I)GTFPE{id0+e*NYz&NErZZRsm?*eR<@1HIyfg#T1*CP=)m*PCcXSBUQx?^M z-MhW?p^cjr>)sV%|9b$j(m5E)&ERY50OPBXpnx|%;ALBWG>Y?O8McfQXD)A_q3z)WmB&ZMa%|r0mv270)Z>Ots_TsTZ@piuIu@O= z+gG!6#}PHrE(5Y}cBz&E=1Wyy1@>gWev9l^dr>EkY3#tXbYr4j*ezX{Xcwlf$F-X~ z1>0|->LBuph`e(>00cMyyr^#Rhb6{07f-!EQ7?sMp8-<9Y2X_X8Ji#HUjxhoxd(W- n6l)Mj0!a}Wxz^Y>^q=T2@m+kk(^7Wd00000NkvXXu0mjf|4R=M literal 0 HcmV?d00001 diff --git a/docs/public/logo/favicon-48.png b/docs/public/logo/favicon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..6ba1993081a2ab854dd129e905969a5672f9449a GIT binary patch literal 1634 zcmV-o2A%ndP)`e`jX)y>VhY-d%U?c>Rb{Xh{PRQkx1y10@wi zd}$k6B&q~bq)?-EkYKJ3G@FYm+l7w}(N&D?wL-1GnMJ?GrJv#^bAY-1bS_c`Pe%76{ zbf@FDXXn0m?9Hw4v0(v`lTSn~!`ka8BkIVozzD*K!id1G!mu35(>c@l%>0?Vr#I@; zjiv?k96uK`EqT8nZ&9>Ja0Cdq;BFFY_1&pQ@1JO$YHe7}42qsS_pl&e1bm9-skG%z z`?1Z(fWvJ)UIPsUL?1kNT2#Ka8O|a*{R~FJcYNu}!o#;DIu*zQe-x4P?asOG7kR@c zo(VddnMUboYBGV|&LD%45Q&~JiAb2iNVsqxBIK+4eO&-fh{)es9(Z}2Umn^=&@|$} z$ACvRML@=K{^fJ36+Cp;K89mE$k+}a|MO*DT4-;NG6Z5iFLA$zzb~eFXJ-iDbEKWZ zdP{Au(U8Jf;8P;9Qp?!HKA>YDB>^1)5Am)b058lg)+K#!yo-C{UEJIe#M6kRV`IO5 z6?^6~cIq;^n!$VX9a#Nu1vm-xs_KVC#HoC=p#Vk31a7VAm+CkVTPA1UF;onPHQ1}z z^OM-AadHKs_RM(xI+sLq+4AusgpQsjeBYNra+ zD*KXi`J^2P`pWum>k9MUp8U3P{n1k_o_UCsi@(C2xzZYW7nXlLTD;w>s ziPnQ=)7WoJ6`LcK_J#tc&U|EE;hMWIIkyBRwN#p$#6-fpROA2|^JJV2tN zlui`$VCxdOtP-^(popuZ#}~BUA`PUSi1WlKU zV9?#))Qhcp5Ru=g>PLam{(v{|)tma)Y`!2yXD`meRQ(BBfM@VP@%A-$|3*Rx01LP+n?05uzL3r zI(nMUFFcEw9)MSX$IE=T@1Z_a^+Dhas{H4NpJn;^@7EkBmKQUUz>E%J#zrusLs-#4 z%-AsI?taLVE+PrMPo(_!$yNz4faicC0NGc5&&q}8z%Z~PgP5@)%;+#?cf6T1(=6~4 z;431sP-)zbvz*2M4(`QP4Su50l#AyVh{AY1$o4M5=~)p6j)Lf30llf1ef g$!%<78~?-jFF!RCau_KYxc~qF07*qoM6N<$g1NFITmS$7 literal 0 HcmV?d00001 diff --git a/docs/public/logo/favicon.ico b/docs/public/logo/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..89b656c90420adca919f755ed874e836498e558e GIT binary patch literal 3362 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 literal 0 HcmV?d00001 diff --git a/docs/public/logo/favicon.svg b/docs/public/logo/favicon.svg new file mode 100644 index 0000000..a21091c --- /dev/null +++ b/docs/public/logo/favicon.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/icon-192.png b/docs/public/logo/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8efbb8e56d7b6c8e2430727bec7caf4b75d718a2 GIT binary patch literal 6998 zcmbtZ^;6XC+x_e=v2-lbu{206-Mw@O0)pf&lG37pAh?7Q0!m1ybR#Xfbc3KEA>EA# z4=KES|Au$&x$g7pHP@UobLN~#1APq=LV7{~07$enk&o{Y`@ezW-T#k&yW-yio~@1s z61e-{%V{f0000IeEu^xsU&cYE^GnO#&;56e);cv$j-EeVoN|-kZXm{cfdZ#sB*6qI zf_ZX%0FBSZk&2nI6^|yunYs%eVu=bZ{J7{&WFUTaw$;c+S_! z^}fCfmvFk)f$6fw)4il!Us9G&E$&wJi+3hd70r!6jC$ULlNkN~8`kjG8zG@t@r2L1 zik+B&pLk^-b6PF04kl45L|E?>!eryvWMOtBUN{{i9=mQ2s{=g|>L&pUcOB-E9issM z@@qDt{pZEZ7pwLr1?YFMXJ|YjyA&0S7D@ysrj{p@LKD)e0q>>uSFCKWVsv3;Y0NFT zvn0vl@=hQ#`@uEm&Y9vr2m=l&>mFS-a-Q8l>^SbH&wx-fU7ZMUW%%!eVUP0r5=Mh) zS*q*2eB5czi8r335DFiOSK%k;Qm4h@0HBV*{Q?;FEm>s!Vu#CyTV{}K!Vs|xh)VEj z@`D5`G=$N?GlI1XhZF*Z(Bdmy?0>P3FdSXBpf^oy-)Iyn8NyIuBhs^n7~(VOl19); zjBLS(ly2Z1i0bMfd-x^nN8@>=*)e7*HlK}`598%)9325db5Y0~k|^LRL6zD&7CD*0 zNk>eQyMl@1QI`ig598>;JSB*}b-Lt`bw)fJz(5$2v*ganI{s0vOS0YO>D)mhdx^?p zB6x@Eff%TrNQoCi5pc9d>flvWH2#VaP-9hNGsQz{9c{0Z zCpgRCdC`oQV*WTTC?|Fr;~APAZ>9UF7#?8)7qnRyV=?o9DkS^6K!`dIEs)bR1+;F6%5}CDf!xc$Uk}zG{ zC~_?$Fjp^XbA25-629g8dcyhd3GPu&>&=PqtF6FrWyP?z36!yVMiTgO>go({)%%&qX+nn?~B7$+0qpg~a0}PJ;XO z-wTz{rm+r&)y7n^qvW`V&1b7!JHy>CleBgHZZlUb+dNoVc7hULOe(V|?t{LQ&qHJ& zGh@p|6z-T2Gso1;HZ-sv0{ehSIwm1fl(Nx3*&w&KDy4aANvAzdHL3<4rK_WZv8VLsm zQdE`^!K##uToDL==8N2*Ykasq7G|Y+=d;~;9(nW=#taAVP&V|3#4kuHP z>^fH@K`3RVkw=dOLx0Te*Xyxjug)X^=WtT`ga~Q=r(sGdD)PM*Dgalgy@zHpc6se( zRy^6YGkSUKv~|QeS8u-R!$_(E>N)JS0`9|Bw^2W5oaZL z+MwQf;KgV$1$*|9U*1Js!jC$I24V&TNaOVnpprfUO}{UN;`}ICG3=#P^CRU-Ab))U z8HbZTHyThY0Tv;-e>q}^r>l&3`Z$brw9=FzXhdKF?rW}NkLt=VlqB`ZeKY+R$jYwE z-CZK-`q!omv{+Kl8K3nIWYE3!M0L~1(E!5YC3r9Msnh^QmL5X4H?kwkB$^fBcjN#} zmx6kd3J$|Dio(qP;vEgC8B#OggQZfJ%S+`F6oW8}ez$sR42@5KIX^^41UxH$-jXl_D(vUH z_-3bg`hc9SFfo((a~sLT-Jx$?!9VVu*Qt%lUh?qOCMYil2I=-pgGw=5m}}g?Is*5e z>|E~?G6%bnb#;XWSFLHUdpk7$3ugi7ldR@rvkZr1qREGb$1fVI_}cPAT@#HHdqhp< zFSJx}l;B47G5e9mJaO!o)Ihh%{MiX<|2HX-B0oCqDGlbg=&2*gyy}(KARB~h&*xe! z^ku_y21Tt1fTMO73ZU0M(I;4bnZpBUi=%p@`FyOsZ{1}N0>gjWAiQSp&MmypM$Exf z-7|91pGgNeop3NGED<}WaZ&9lb-B+emA1Xf`A=zV*d*#0UrLbrO?`a?Ypai}h4 z+;7bB<+TD8vm3TJ7BaBo-fJIV-Le(0_O0scr#Q%h8;f2Aa?*M(nH+=^wX2_~Xs}R? zGJ>;NmR=A8%cd@yqkj7JCN-=Px+Pb`wNpo?Y2fIa28U>ulKz>zGuoxc;a@fO0&Y9% znN@9zkfBHR%hsA*14{b+i)t5&pc@5w_T!P`&*5Zc(z^l#6xenR9SfqNhv_*DEwp$k zis9*H$M z2lz+~KT%6I7qOVs=&+FycMf-a;BSL)Y=z5VX!}lrqLcMf_!(M`4*dP;U}w|(T^v%f z)D=J@MV?&8*8^pL-1aBy`eyZ^iw4E;=#&qmi|n7*l=TbDk-JfPloK3$1K9E` ztI~3(3n!gF%WzfKoX*pfKoPTGf99?U6XoTTNv_tvar_{?RgfLc02E< z$~GLxqw%oGG3zS%R)OY|dayC$F!VIIRB=$ndvKeP`hXQmsPhFO99|~dZj;#WHn637 zyp`Jk`txq&k7PRahBb|)_bG{@<-LEPwJW_vhxKZz{mywAKeUC8Z*Kb08NZ?``=hx_ zB?e>|^YS~&h2!Q*3z92gX`SU6e?&e69Ao%(Vu>e0Io$#gjD zj?Dvi^vjWvyOL_(@})oh6N=pB&jq6%E5fLjqk~wCy>A`OfqhdkCUET*EVk`~cl*OI zKNwc5>YAPhu^mA2;RWn*7gsL0+M7q;{EbnWZ3+Txr=r2F?2C?8__mJ$!2DeXw{chCI;k9eI;sLfR=OcarAkoVM49tUkRbNFT1 zWE}q#e=G+>VynM4D*QTHXxi~Q6UWTwe(z)Y)+o}g|0J!@Lr%zbQ}wX$%9v9k{|@Jp z>DSork6F!Wgq3FVPPJeNM@q**k~|53GFzzlmSs(xa%Li0-o!N5*RBCD@?(jdw|!Cb%@1@Hvc z^v>7@ny_*~S^RFH>YT+Mu+e(hh@ zq<0z1Ki_ZG5zB$9jcxFWzZwjA{1T@TSMhp@rddRA@P4jDX^X3~@YA{P&2jNv7SY=eC6ExA6nnz@q2#O2u^NEq8m3Z_Maan@jZa!> z2L?iBHuBpF6`Kb5hTEYzL6;vo_%8A3b)m$TUK#%PN~gIP zp_#s)I?v*SM>+GZ-(JpQB|$ThICSsd1#}OLZFk{1-u>yeG;bY`m-M&;>8~&z;7>kXpQ%5pC1kD#^viasJPMXWo&Yd_ zZgO$>Nl zk7J-il>c*q7a%)D|7WI)bFb#=I??XYIQ`k+j0w|j00aFVu@nfvT*W~0*3jnfeZlSko0THt)CX4HODRENxUh?}KZ z#n)(P{#KLqo)kBStn+KT0w~Z70 z$;5wPMA@}}5au&ZvXHK6Wx4zNWc&-f>M{k1$r)ZzxXKk1x3?X~*4xPab^Hli&lC*H zRn28y3kqqyOVthA+OLc6c@)cACUp>EhE=aH2+uVj#i;}2!sH`xE(HOKEpEVqW`0w| z$>@Jk!=t}PktGkz+oeBstnV1C9DJ@78hPUylo8$J-$AcqCiuxAGruXJI;VUU7wdyA z@j)ysG}(4~e7swEkS>Y;yybm8MAN9czRck;+K7$BNax`s*@kTm6i?hyg!^6fcSGmoMq zo|vfGTaSM_*z;KmJKRPBXs}9~;{-P_?JoGH=`ZMw#BJ=CBl2kWIzf7C^(@*Xm$RY1S^Q^bVf-y- zz(<0w9G11vS_%S^|AOPS0RXWIy%PH6f$^)Xi%kAEWYf&j0B%$h&eS9ga+3 zv5Nm-O!I@aPS1Si+-gJ0iT3mk6^}X|eJ@l)LaZnpGx+9RKxcYB3;=%K?ry3wc25Br zy)e#D*`I~ViJ5pWXc-q(amR?A#*pUTD+a$xy|@biOctL>i)^!k&T?|*Ok9ODK|wf>*P;+_naDL5BC1085wsBrZ9Y zwRUP+_cxvD*3|-P5Fm#*Ny7CIYEV0EtQfJeqrKgM7)#b$?L)6KIlFbzVdv1^zpC{1 z3Or^%WTr?|d3iC zN22Nu#ro&ZMMWoz+7(@%modGir`c*H?z`9F)al3QUT*3qK0jsZ6R3)m7jn+8rk2ib zeZgu(4M8keGAcd&?d@HED3+;#ahG?z756M)ro6%~)QECjAaQ&B- zndI{#UVNH0IckHH5QN;+eM^?pcu;5m1z(k+LBPa~+m&j2iU&LS*bCBI5n(E_-sOx8nIks5-!|-k%j*tHa;^bY6Nt<_k^ZSl!7=v1<%= z^RT|~EtKbF_gD@Ec3#v`z4KH+Lmev0x>NDJuT=59gj=TXef$xaoyz0rbr~;^*AjCI ziM{%f!5qT3rH7|W`}3h1L#aeCVxH5x0x%6Op$>CLd}RBAfT#f?%(H%E4q|TJC48e= zKdH){U$v6bBG3k9P-^2jKcLFm$2GcKH7i2NqvVM z#kpxO9{1)n$lZbY50mhPJ^|pX14~iKBFFirRzG)mKRH6bbTrxald$vZNDpRuM|F&Z zWxqzZ%YS+YNtIDvge;EQEUXTxuPxtmp8U_4v}pU_h&}vV=v&!1(_VuXNsHvGDe$)a zR_k9=@*XvK&@a6f8On8;@E_5qpWW$xfTdywGIowgSa^lt%ND@g@9J+FAA!oQoNM(; znVHojc>zp3YxcV8?0CYa+1c|u(>sHWjN~Q8 zStgwhTDpto#Ln3EsGP>dhOuGfaa$!J>M8D+}lh z=HU^lVAjk`DdX!)=c3(V96xkkhEEjf1hKD4QVp z(n9ov#cIU_-=87_D0Ee#fd&5ZP<%|dnaQ!OEyNO}L^H2}}Hc+PNqnVAPpFC&a*AC2&Rd4IFyRVK4bIaNZf6yj{27$_b( z{fwN8J+mi>ydhy-K=;%Z(RgNJ?$EYbX5j?7xHq}`Lc$Z;#K*impm3lR)Py%8PX6UR zEla*V?GcI1>x=58TaY=>KEW5Vv0c>U5#NpT)B909NkXKqW-M)p9>NSljN z0AO*pO!+5GUIF@D;<-%Wg*gZ8L^`SB+2QCKyIS_zsfz6r>3e0FWBSiX$ ztH>l9?iOR^tGujRB~>E0mNU;_1{@qbi@KyE&m_tLlt}N1l15-WjDm6KI!LUsF>Be~ hSpL6jq^5y8h_Sq(*tu_0?tM`PXsPKVD^*Y-{|8Q-$)NxM literal 0 HcmV?d00001 diff --git a/docs/public/logo/icon-512.png b/docs/public/logo/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..82eb646e6799eec4270c96c181c213283d502ff3 GIT binary patch literal 19770 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 YQcQxOkx`Q_|7SmFuOq+LdW2s2FL7cf8UO$Q literal 0 HcmV?d00001 diff --git a/docs/public/logo/maskable-512.png b/docs/public/logo/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..237ed6205a9b88982d2417d82873c280993379a8 GIT binary patch literal 14904 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 Date: Wed, 7 Oct 2026 14:07:26 +0700 Subject: [PATCH 4/7] fix(server): harden /api/render (scale/slide caps, template names, validation, empty key) (#130) * fix(server): harden /api/render (scale/slide caps, template names, validation, empty key, --host) Closes #127 Co-Authored-By: Claude Sonnet 5.5 * test: avoid slow max-scale render in scale-cap test Co-Authored-By: Claude Sonnet 5.5 * refactor(server): extract non_blank helper for key normalization Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: ajianaz Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 14 ++++++ docs/api/endpoints.md | 2 +- docs/guide/server.md | 3 ++ src/cli.rs | 12 ++++- src/server.rs | 107 +++++++++++++++++++++++++++++++++++++++++- tests/api_server.rs | 105 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a53b2d..6f20dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Cosy will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security +- `POST /api/render` hardening (#127): `scale` limited to 0.1–4.0, at most + 20 slides per JSON request, renders bounded by a CPU-sized semaphore, + `template` must be a plain name (no filesystem paths), and input is + validated against the template schema like the CLI/signed GET route. +- An empty `COSY_API_KEY` (docker-compose default) now means "auth + disabled" instead of an unusable empty secret / empty signing key. + +### Added +- `cosy serve --host` to choose the bind address; a warning is logged when + auth is disabled on a non-loopback address. + ## [0.5.0] — 2026-09-30 ### Added diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 146090e..176b3fc 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -43,7 +43,7 @@ Renders an image from a template. |-------|------|----------|-------------| | `template` | string | Yes | Template name (e.g. `"stat-card"`) | | `data` | object | Yes | Template input data (brand + slides) | -| `scale` | float | No | Scale factor (default: `1.0`) | +| `scale` | float | No | Scale factor, `0.1`–`4.0` (default: `2.0`); out of range → `400` | | `slide_index` | int | No | Zero-based slide to render with the default `png` format (default: `0`) | | `response_format` | string | No | `"png"` (default, binary image) or `"json"` (all slides as base64 entries) | | `image_format` | string | No | `"png"` (default), `"webp"`, or `"svg"` — container for the rendered bytes, independent of `response_format` | diff --git a/docs/guide/server.md b/docs/guide/server.md index 4b14ecc..12cdd9b 100644 --- a/docs/guide/server.md +++ b/docs/guide/server.md @@ -11,6 +11,9 @@ COSY_API_KEY=your-secret cosy serve --port 3000 # Or via flag cosy serve --port 3000 --token your-secret + +# Bind to loopback only (recommended for local dev without a key) +cosy serve --host 127.0.0.1 ``` ## Remote Image Security diff --git a/src/cli.rs b/src/cli.rs index 01f9c8f..f16e5c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,11 @@ pub enum Command { #[arg(short, long, default_value = "3000")] port: u16, + /// Address to bind. Defaults to all interfaces (needed in Docker); + /// use 127.0.0.1 for local dev without auth. + #[arg(long, default_value = "0.0.0.0")] + host: String, + /// API key for bearer token auth. If not set, reads COSY_API_KEY env var. /// When neither is set, auth is disabled (dev mode). #[arg(short, long)] @@ -373,12 +378,15 @@ impl Cli { Command::Serve { port, + host, token, allow_private_images, allow_local_image_paths, } => { // Resolve API key: --token flag takes priority, then COSY_API_KEY env - let api_key = token.or_else(|| std::env::var("COSY_API_KEY").ok()); + let api_key = token + .or_else(|| std::env::var("COSY_API_KEY").ok()) + .filter(|k| !k.is_empty()); let image_policy = crate::text::ImagePolicy { allow_private: allow_private_images, allow_local: allow_local_image_paths, @@ -392,7 +400,7 @@ impl Cli { println!("Starting Cosy API server on port {}...", port); // Tokio runtime for async server let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(crate::server::run(port, api_key, image_policy))?; + runtime.block_on(crate::server::run_on(&host, port, api_key, image_policy))?; Ok(ExitCode::SUCCESS) } } diff --git a/src/server.rs b/src/server.rs index f12c55d..9d2f195 100644 --- a/src/server.rs +++ b/src/server.rs @@ -27,6 +27,9 @@ use tower_http::cors::CorsLayer; /// Shared server state — font DB built once at startup. struct AppState { font_db: usvg::fontdb::Database, + /// Bounds concurrent renders so a burst of requests cannot exhaust + /// CPU/memory (each render allocates a full-size pixmap). + render_slots: tokio::sync::Semaphore, image_policy: crate::text::ImagePolicy, api_key: Option, /// Signing key for GET /r/:template signed render URLs. Falls back to @@ -69,6 +72,27 @@ pub struct RenderRequest { /// Maximum serialized size of the optional `metadata` field. pub const MAX_METADATA_BYTES: usize = 4096; +/// Accepted `scale` range for POST /api/render. The pixmap is +/// `width*scale × height*scale` RGBA and is copied twice while encoding, so an +/// unbounded value lets one request exhaust memory (and `panic = "abort"` +/// takes the whole server down with it). +pub const MIN_SCALE: f32 = 0.1; +pub const MAX_SCALE: f32 = 4.0; + +/// Maximum slides rendered by one request (`response_format: "json"`). +pub const MAX_SLIDES_PER_REQUEST: usize = 20; + +/// True for a plain template id (`stat-card`). The HTTP API only serves +/// templates from `./templates`; path-like values (`/tmp/x`, `../x`, `a/b`) +/// would let callers render arbitrary directories on the server. +fn is_safe_template_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + /// Image container format for POST /api/render (`image_format` field). #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -244,6 +268,11 @@ pub fn signed_get_url( format!("{base}/r/{template}.{ext}?d={d}&sig={sig}") } +/// Treat an empty string as "not set". +fn non_blank(value: Option) -> Option { + value.filter(|v| !v.is_empty()) +} + /// Start the HTTP server. /// /// If `api_key` is Some, all endpoints except /api/health require @@ -253,6 +282,22 @@ pub async fn run( api_key: Option, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result<()> { + run_on("0.0.0.0", port, api_key, image_policy).await +} + +/// Like [`run`], but with an explicit bind address (`--host`). +pub async fn run_on( + host: &str, + port: u16, + api_key: Option, + image_policy: crate::text::ImagePolicy, +) -> anyhow::Result<()> { + // Blank values (docker-compose passes one when the variable is unset) + // mean "not configured". + let api_key = non_blank(api_key); + let ip: std::net::IpAddr = host + .parse() + .map_err(|_| anyhow::anyhow!("invalid --host '{host}': expected an IP address"))?; let font_db = render::build_font_db(None)?; let template_count = template::list_templates(std::path::Path::new("./templates")).len(); @@ -262,10 +307,19 @@ pub async fn run( log::info!("Authentication enabled (bearer token required)"); } else { log::warn!("Authentication disabled — set COSY_API_KEY to secure the API"); + if !ip.is_loopback() { + log::warn!( + "Listening on {ip} without authentication — anyone who can reach this port can render; use --host 127.0.0.1 for local dev" + ); + } } + let max_concurrent = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2); let state = Arc::new(AppState { font_db, + render_slots: tokio::sync::Semaphore::new(max_concurrent), image_policy, api_key: api_key.clone(), signing_key: api_key.clone(), @@ -289,7 +343,7 @@ pub async fn run( .layer(CorsLayer::permissive()) .with_state(state); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::new(ip, port); log::info!("Cosy API server listening on http://{}", addr); log::info!("Loaded {} templates", template_count); @@ -410,6 +464,21 @@ async fn render_handler( ); } + if !req.scale.is_finite() || !(MIN_SCALE..=MAX_SCALE).contains(&req.scale) { + return error_response( + StatusCode::BAD_REQUEST, + format!("scale must be between {MIN_SCALE} and {MAX_SCALE}"), + ); + } + + if !is_safe_template_name(&req.template) { + return error_response( + StatusCode::BAD_REQUEST, + "Template error: template must be a plain template name (letters, digits, '-', '_')" + .into(), + ); + } + // Load template definition let tmpl = match template::load_template(&req.template) { Ok(t) => t, @@ -426,6 +495,26 @@ async fn render_handler( } }; + // Same schema validation as the CLI and signed GET route. + let errors = template::validate_input(&tmpl, &req.data); + if !errors.is_empty() { + return error_response( + StatusCode::BAD_REQUEST, + format!("validation failed: {}", errors.join("; ")), + ); + } + + if format == ResponseFormat::Json && req.data.slides.len() > MAX_SLIDES_PER_REQUEST { + return error_response( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "too many slides: {} (max {} per request)", + req.data.slides.len(), + MAX_SLIDES_PER_REQUEST + ), + ); + } + // Which slides to render: all for json format, one for image format. let slide_indices: Vec = match format { ResponseFormat::Json => (0..req.data.slides.len()).collect(), @@ -457,6 +546,11 @@ async fn render_handler( req.image_format.map(Into::into).unwrap_or_default(); let out_w = (dims.width as f32 * scale).round() as u32; let out_h = (dims.height as f32 * scale).round() as u32; + let _permit = state + .render_slots + .acquire() + .await + .expect("render semaphore is never closed"); let render_result = tokio::task::spawn_blocking(move || { slide_indices .into_iter() @@ -618,6 +712,12 @@ async fn signed_render_handler( } // 5. Load template + dir (error mapping mirrors render_handler) + if !is_safe_template_name(&template_id) { + return error_response( + StatusCode::BAD_REQUEST, + "Template error: invalid template name".into(), + ); + } let Ok(tmpl) = template::load_template(&template_id) else { return error_response( StatusCode::BAD_REQUEST, @@ -644,6 +744,11 @@ async fn signed_render_handler( let image_policy = state.image_policy; let scale = 1.0_f32; let dims = tmpl.dimensions.clone(); + let _permit = state + .render_slots + .acquire() + .await + .expect("render semaphore is never closed"); let render_result = tokio::task::spawn_blocking(move || { let pixels = render::render_slide_to_pixels( &tmpl, diff --git a/tests/api_server.rs b/tests/api_server.rs index 32708e1..d1d754c 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -876,3 +876,108 @@ fn test_signed_get_bad_extension_400() { let resp = http_client().get(&signed).send().unwrap(); assert_eq!(resp.status(), 400); } + +// ─── Hardening (#127) ─────────────────────────────────────────────── + +fn render_body(extra: serde_json::Value) -> serde_json::Value { + let mut body = serde_json::json!({ + "template": "stat-card", + "scale": 0.5, + "data": { + "brand": {"brand_name": "T"}, + "slides": [{"stat_number": "1%", "stat_label": "x", "source": "x"}] + } + }); + for (k, v) in extra.as_object().unwrap() { + body[k] = v.clone(); + } + body +} + +fn post_render(url: &str, body: &serde_json::Value) -> reqwest::blocking::Response { + http_client() + .post(format!("{}/api/render", url)) + .json(body) + .send() + .unwrap() +} + +#[test] +fn test_render_scale_out_of_range_rejected() { + let url = start_server(); + for scale in [0.0, -1.0, 0.01, 4.5, 12.0, 1e9] { + let resp = post_render(&url, &render_body(serde_json::json!({ "scale": scale }))); + assert_eq!(resp.status(), 400, "scale {scale} must be rejected"); + } + // 4.01 is just over the cap; the in-range path is covered by every + // other render test (a 4.0 render is too slow for a debug-build test). + let over = post_render(&url, &render_body(serde_json::json!({ "scale": 4.01 }))); + assert_eq!(over.status(), 400); +} + +#[test] +fn test_render_path_template_rejected() { + let url = start_server(); + for name in [ + "./templates/stat-card", + "templates/stat-card", + "../cosy/templates/stat-card", + "/tmp", + ".", + "", + ] { + let resp = post_render(&url, &render_body(serde_json::json!({ "template": name }))); + assert_eq!(resp.status(), 400, "template {name:?} must be rejected"); + } +} + +#[test] +fn test_render_validates_input() { + let url = start_server(); + let body = serde_json::json!({ + "template": "stat-card", + "scale": 0.5, + "data": {"brand": {}, "slides": [{}]} + }); + let resp = post_render(&url, &body); + assert_eq!(resp.status(), 400); + let json: serde_json::Value = resp.json().unwrap(); + assert!(json["error"] + .as_str() + .unwrap() + .contains("validation failed")); +} + +#[test] +fn test_render_json_too_many_slides_413() { + let url = start_server(); + let slide = serde_json::json!({"stat_number": "1%", "stat_label": "x", "source": "x"}); + let slides: Vec<_> = (0..server::MAX_SLIDES_PER_REQUEST + 1) + .map(|_| slide.clone()) + .collect(); + let body = serde_json::json!({ + "template": "stat-card", + "scale": 0.25, + "response_format": "json", + "data": {"brand": {"brand_name": "T"}, "slides": slides} + }); + assert_eq!(post_render(&url, &body).status(), 413); +} + +#[test] +fn test_empty_api_key_means_auth_disabled() { + // docker-compose passes COSY_API_KEY="" when unset + let url = start_server_with_key(Some(String::new())); + let resp = http_client() + .get(format!("{}/api/templates", url)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let health: serde_json::Value = http_client() + .get(format!("{}/api/health", url)) + .send() + .unwrap() + .json() + .unwrap(); + assert_eq!(health["auth_enabled"], false); +} From 9da9ca47d05129f681e8fbee2dfd3415688b1ca2 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 7 Oct 2026 14:19:37 +0700 Subject: [PATCH 5/7] perf: share font DB, move pixel buffers, cache SVG sources and remote images, cheap health (#133) Closes #128 Co-authored-by: ajianaz Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++++ src/batch.rs | 10 ++++---- src/format.rs | 10 ++++++-- src/render.rs | 56 ++++++++++++++-------------------------- src/server.rs | 24 ++++++++++------- src/template.rs | 61 ++++++++++++++++++++++++++++++++++++-------- src/text.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f20dca..cc4251f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `cosy serve --host` to choose the bind address; a warning is logged when auth is disabled on a non-loopback address. +### Performance +- Render pipeline (#128): the font database is shared as `Arc` instead of + cloned per slide, pixel buffers are moved (not copied) into the encoder + (`OutputFormat::encode_owned`), `template.svg` sources are cached by + mtime, and remote images are cached for 5 minutes (32 entries / 64 MB). +- `/api/health` returns the startup template count instead of re-parsing + every `schema.json`; `/api/templates` reads off the async workers. + ## [0.5.0] — 2026-09-30 ### Added diff --git a/src/batch.rs b/src/batch.rs index f64e22b..5adbd21 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -317,7 +317,7 @@ fn empty_input() -> InputData { fn render_one_row( template: &TemplateDef, template_dir: &Path, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, scale: f32, format: OutputFormat, @@ -354,7 +354,7 @@ fn render_one_row( font_db, image_policy, )?; - let bytes = encode_pixels(template, scale, format, &pixels)?; + let bytes = encode_pixels(template, scale, format, pixels)?; let path = dir.join(format!("{:02}.{}", i + 1, format.extension())); std::fs::write(&path, &bytes)?; out.push(path.to_string_lossy().to_string()); @@ -372,7 +372,7 @@ fn render_one_row( font_db, image_policy, )?; - let bytes = encode_pixels(template, scale, format, &pixels)?; + let bytes = encode_pixels(template, scale, format, pixels)?; let path = out_dir.join(format!("{}.{}", job.stem, format.extension())); std::fs::write(&path, &bytes)?; Ok(vec![path.to_string_lossy().to_string()]) @@ -396,11 +396,11 @@ fn encode_pixels( template: &TemplateDef, scale: f32, format: OutputFormat, - pixels: &[u8], + pixels: Vec, ) -> anyhow::Result> { let w = (template.dimensions.width as f32 * scale).round() as u32; let h = (template.dimensions.height as f32 * scale).round() as u32; - format.encode(pixels, w, h) + format.encode_owned(pixels, w, h) } /// Split a `_data` document into (brand, slides). Accepts: diff --git a/src/format.rs b/src/format.rs index 4f4daea..1dccaf9 100644 --- a/src/format.rs +++ b/src/format.rs @@ -77,6 +77,12 @@ impl OutputFormat { /// WebP is lossy at quality 90: visually indistinguishable for social /// cards while cutting file size ~3-5x vs PNG. pub fn encode(&self, pixels: &[u8], w: u32, h: u32) -> anyhow::Result> { + self.encode_owned(pixels.to_vec(), w, h) + } + + /// Like [`OutputFormat::encode`] but takes ownership of the pixel + /// buffer, avoiding a full-frame copy (the hot render paths use this). + pub fn encode_owned(&self, pixels: Vec, w: u32, h: u32) -> anyhow::Result> { assert!( self.is_pixel_based(), "SVG output is produced via render_slide_to_svg, not encode()" @@ -89,7 +95,7 @@ impl OutputFormat { h )); } - let img = image::RgbaImage::from_raw(w, h, pixels.to_vec()) + let img = image::RgbaImage::from_raw(w, h, pixels) .ok_or_else(|| anyhow::anyhow!("Failed to wrap pixel buffer as RGBA image"))?; let mut out = std::io::Cursor::new(Vec::new()); @@ -103,7 +109,7 @@ impl OutputFormat { // (lossy requires libwebp via the `webp` crate — C dep, // deliberately avoided in this build). let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut out); - encoder.write_image(pixels, w, h, image::ExtendedColorType::Rgba8)?; + encoder.write_image(img.as_raw(), w, h, image::ExtendedColorType::Rgba8)?; } // Unreachable via the assert above; kept exhaustive for the // compiler (SVG bypasses the pixel path entirely). diff --git a/src/render.rs b/src/render.rs index ba22d79..a5124a9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -60,7 +60,7 @@ pub fn render_template_data( if data.is_single_slide() || output.extension().is_some() { // Single image output if format.is_pixel_based() { - let png = render_slide( + let png = render_slide_to_pixels( &template, &template_dir, data, @@ -69,8 +69,8 @@ pub fn render_template_data( &font_db, image_policy, )?; - let bytes = format.encode( - &png, + let bytes = format.encode_owned( + png, output_width(&template.dimensions, scale), output_height(&template.dimensions, scale), )?; @@ -97,7 +97,7 @@ pub fn render_template_data( let filename = format!("{:02}.{}", i + 1, format.extension()); let path: PathBuf = output.join(&filename); if format.is_pixel_based() { - let png = render_slide( + let png = render_slide_to_pixels( &template, &template_dir, data, @@ -106,8 +106,8 @@ pub fn render_template_data( &font_db, image_policy, )?; - let bytes = format.encode( - &png, + let bytes = format.encode_owned( + png, output_width(&template.dimensions, scale), output_height(&template.dimensions, scale), )?; @@ -173,27 +173,6 @@ fn output_height(dims: &crate::schema::Dimensions, scale: f32) -> u32 { // ─── Render Single Slide ──────────────────────────────────────────── -/// Render a single slide to raw RGBA pixels. -fn render_slide( - template: &TemplateDef, - template_dir: &Path, - data: &InputData, - slide_index: usize, - scale: f32, - font_db: &usvg::fontdb::Database, - image_policy: crate::text::ImagePolicy, -) -> anyhow::Result> { - render_slide_to_pixels( - template, - template_dir, - data, - slide_index, - scale, - font_db, - image_policy, - ) -} - /// Render a single slide to raw RGBA8 pixels (public API for /// format-aware callers — pair with `OutputFormat::encode`). pub fn render_slide_to_pixels( @@ -202,7 +181,7 @@ pub fn render_slide_to_pixels( data: &InputData, slide_index: usize, scale: f32, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result> { let slide_data = &data.slides[slide_index]; @@ -221,7 +200,7 @@ pub fn render_slide_to_pixels( // 2. Parse SVG via usvg with font database let opts = usvg::Options { font_family: "Inter".to_string(), - fontdb: std::sync::Arc::new(font_db.clone()), + fontdb: std::sync::Arc::clone(font_db), ..Default::default() }; @@ -240,8 +219,9 @@ pub fn render_slide_to_pixels( let transform = tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); - // 5. Return raw RGBA pixels for container-format encoding - Ok(pixmap.data().to_vec()) + // 5. Return raw RGBA pixels for container-format encoding (moved out of + // the pixmap, not copied) + Ok(pixmap.take()) } /// Render a single slide to a self-contained SVG string (public API for @@ -258,7 +238,7 @@ pub fn render_slide_to_svg( template_dir: &Path, data: &InputData, slide_index: usize, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result { let slide_data = &data.slides[slide_index]; @@ -276,7 +256,7 @@ pub fn render_slide_to_svg( // the font DB, resolves hrefs, normalizes the tree)… let opts = usvg::Options { font_family: "Inter".to_string(), - fontdb: std::sync::Arc::new(font_db.clone()), + fontdb: std::sync::Arc::clone(font_db), ..Default::default() }; let tree = usvg::Tree::from_str(&svg_string, &opts)?; @@ -296,7 +276,7 @@ pub fn render_slide_to_png( data: &InputData, slide_index: usize, scale: f32, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result> { let pixels = render_slide_to_pixels( @@ -310,7 +290,7 @@ pub fn render_slide_to_png( )?; let w = (template.dimensions.width as f32 * scale).round() as u32; let h = (template.dimensions.height as f32 * scale).round() as u32; - OutputFormat::Png.encode(&pixels, w, h) + OutputFormat::Png.encode_owned(pixels, w, h) } // ─── Font Database ────────────────────────────────────────────────── @@ -321,7 +301,9 @@ pub fn render_slide_to_png( /// 1. Bundled fonts (Inter Regular/Bold/SemiBold, JetBrains Mono Regular) /// 2. System fonts /// 3. User-specified --font-dir -pub fn build_font_db(custom_dir: Option<&Path>) -> anyhow::Result { +pub fn build_font_db( + custom_dir: Option<&Path>, +) -> anyhow::Result> { let mut db = usvg::fontdb::Database::new(); // 1. Load bundled fonts (embedded at compile time) @@ -437,7 +419,7 @@ pub fn build_font_db(custom_dir: Option<&Path>) -> anyhow::Result, + /// Template count captured at startup (health checks must stay cheap). + template_count: usize, /// Bounds concurrent renders so a burst of requests cannot exhaust /// CPU/memory (each render allocates a full-size pixmap). render_slots: tokio::sync::Semaphore, @@ -319,6 +321,7 @@ pub async fn run_on( .unwrap_or(2); let state = Arc::new(AppState { font_db, + template_count, render_slots: tokio::sync::Semaphore::new(max_concurrent), image_policy, api_key: api_key.clone(), @@ -405,18 +408,21 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { // ─── Handlers ────────────────────────────────────────────────────── async fn health(State(state): State>) -> Json { - let templates = template::list_templates(std::path::Path::new("./templates")); - let _ = &state; // font_db ready Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), - templates: templates.len(), + templates: state.template_count, auth_enabled: state.api_key.is_some(), }) } async fn list_templates() -> Json> { - let templates = template::list_templates(std::path::Path::new("./templates")); + // Reads every schema.json from disk — keep it off the async workers. + let templates = tokio::task::spawn_blocking(|| { + template::list_templates(std::path::Path::new("./templates")) + }) + .await + .unwrap_or_default(); Json(templates) } @@ -536,7 +542,7 @@ async fn render_handler( // Blocking work (template IO, resvg, possibly remote image fetches) runs // on the blocking thread pool so the async runtime is never blocked. - let font_db = state.font_db.clone(); + let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = req.scale; let data = req.data; @@ -565,7 +571,7 @@ async fn render_handler( &font_db, image_policy, )?; - let bytes = image_format.encode(&png, out_w, out_h)?; + let bytes = image_format.encode_owned(png, out_w, out_h)?; Ok((i, bytes)) } else { // SVG: vector path — scale does not apply. @@ -740,7 +746,7 @@ async fn signed_render_handler( } // 6. Render first slide on the blocking pool - let font_db = state.font_db.clone(); + let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = 1.0_f32; let dims = tmpl.dimensions.clone(); @@ -761,7 +767,7 @@ async fn signed_render_handler( )?; let w = (dims.width as f32 * scale).round() as u32; let h = (dims.height as f32 * scale).round() as u32; - image_format.encode(&pixels, w, h) + image_format.encode_owned(pixels, w, h) }) .await; diff --git a/src/template.rs b/src/template.rs index 6409b89..ecaaa31 100644 --- a/src/template.rs +++ b/src/template.rs @@ -42,20 +42,44 @@ pub fn find_template_dir_for(name: &str) -> anyhow::Result { } /// Load the raw SVG template string. -pub fn load_svg(template_dir: &Path) -> anyhow::Result { +/// +/// Parsed sources are cached per path and invalidated by mtime, so batch +/// renders and the server do not re-read the file for every slide while edits +/// on disk are still picked up immediately. +pub fn load_svg(template_dir: &Path) -> anyhow::Result> { + use std::collections::HashMap; + use std::sync::{Arc, Mutex, OnceLock}; + use std::time::SystemTime; + + type SvgCache = Mutex)>>; + static CACHE: OnceLock = OnceLock::new(); + // Prefer .svg.j2, fallback to .svg let j2 = template_dir.join("template.svg.j2"); let svg = template_dir.join("template.svg"); - let path = if j2.exists() { &j2 } else { &svg }; - - if !path.exists() { - anyhow::bail!( - "No template file found in {}. Expected template.svg.j2 or template.svg", - template_dir.display() - ); + let path = if j2.exists() { j2 } else { svg }; + + let mtime = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .map_err(|_| { + anyhow::anyhow!( + "No template file found in {}. Expected template.svg.j2 or template.svg", + template_dir.display() + ) + })?; + + let cache = CACHE.get_or_init(Default::default); + if let Some((cached_at, src)) = cache.lock().unwrap().get(&path) { + if *cached_at == mtime { + return Ok(Arc::clone(src)); + } } - - Ok(std::fs::read_to_string(path)?) + let src = Arc::new(std::fs::read_to_string(&path)?); + cache + .lock() + .unwrap() + .insert(path, (mtime, Arc::clone(&src))); + Ok(src) } // ─── minijinja Processing ─────────────────────────────────────────── @@ -78,7 +102,7 @@ pub fn process_template( // Build minijinja environment with custom filters let mut env = minijinja::Environment::new(); - env.add_template("slide", &svg_template)?; + env.add_template("slide", svg_template.as_str())?; // Register custom filters env.add_filter("wordwrap", filter_wordwrap); @@ -736,6 +760,21 @@ mod filter_tests { } } + #[test] + fn test_load_svg_cache_follows_file_changes() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("template.svg"); + std::fs::write(&file, "one").unwrap(); + assert_eq!(load_svg(dir.path()).unwrap().as_str(), "one"); + // Same bytes served from cache; then an edit (new mtime) is picked up. + let first = load_svg(dir.path()).unwrap(); + let second = load_svg(dir.path()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(&file, "two").unwrap(); + assert_eq!(load_svg(dir.path()).unwrap().as_str(), "two"); + } + #[test] fn test_filter_wordwrap_returns_wrapped_text() { let result = filter_wordwrap("Hello World Foo Bar".into(), 5); diff --git a/src/text.rs b/src/text.rs index bc2c2eb..48c4f0f 100644 --- a/src/text.rs +++ b/src/text.rs @@ -165,6 +165,63 @@ fn mime_for_extension(ext: &str) -> &'static str { /// /// Download a remote image, returning raw bytes plus its mime type. fn fetch_image_bytes(url_str: &str, allow_private: bool) -> anyhow::Result<(Vec, String)> { + if let Some(hit) = image_cache_get(url_str, allow_private) { + return Ok(hit); + } + let fetched = fetch_image_bytes_uncached(url_str, allow_private)?; + image_cache_put(url_str, allow_private, &fetched); + Ok(fetched) +} + +/// Small in-process cache of successfully fetched remote images so a brand +/// logo / shared background is downloaded once per batch or server session +/// instead of once per slide. Only successful, size-capped fetches are stored; +/// every entry was fetched under the same `allow_private` policy it is served +/// for (part of the key), and expires after [`IMAGE_CACHE_TTL`]. +const IMAGE_CACHE_TTL: Duration = Duration::from_secs(300); +const IMAGE_CACHE_MAX_ENTRIES: usize = 32; +const IMAGE_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024; + +type ImageCacheEntry = ((String, bool), std::time::Instant, Vec, String); + +fn image_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +fn image_cache_get(url: &str, allow_private: bool) -> Option<(Vec, String)> { + let mut cache = image_cache().lock().ok()?; + cache.retain(|(_, at, _, _)| at.elapsed() < IMAGE_CACHE_TTL); + cache + .iter() + .find(|((u, p), _, _, _)| u == url && *p == allow_private) + .map(|(_, _, bytes, mime)| (bytes.clone(), mime.clone())) +} + +fn image_cache_put(url: &str, allow_private: bool, fetched: &(Vec, String)) { + let Ok(mut cache) = image_cache().lock() else { + return; + }; + cache.retain(|((u, p), _, _, _)| !(u == url && *p == allow_private)); + cache.push(( + (url.to_string(), allow_private), + std::time::Instant::now(), + fetched.0.clone(), + fetched.1.clone(), + )); + // Evict oldest until within entry and byte budgets. + while cache.len() > IMAGE_CACHE_MAX_ENTRIES + || cache.iter().map(|e| e.2.len()).sum::() > IMAGE_CACHE_MAX_BYTES + { + cache.remove(0); + } +} + +fn fetch_image_bytes_uncached( + url_str: &str, + allow_private: bool, +) -> anyhow::Result<(Vec, String)> { let url = reqwest::Url::parse(url_str)?; let scheme_ok = url.scheme() == "https" || (allow_private && url.scheme() == "http"); if !scheme_ok { @@ -366,6 +423,17 @@ mod tests { ) + std::str::from_utf8(body).unwrap() } + #[test] + fn test_fetch_url_second_call_served_from_cache() { + // serve_one answers exactly one connection: a second network fetch + // would fail, so success proves the cache hit. + let url = serve_one(response_with("image/png", b"cachedbytes")); + let first = fetch_image_bytes(&url, true).expect("first fetch"); + let second = fetch_image_bytes(&url, true).expect("second fetch from cache"); + assert_eq!(first, second); + assert_eq!(second.0, b"cachedbytes"); + } + #[test] fn test_is_remote_url() { assert!(is_remote_url("https://example.com/a.png")); From 71c00a262ae060131f9d34239c1edda389e39a0c Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 7 Oct 2026 14:26:07 +0700 Subject: [PATCH 6/7] feat(server): dedicated signing key and signed expiry for GET render URLs (#132) Closes #129 Co-authored-by: ajianaz Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/api/endpoints.md | 15 +++++--- src/cli.rs | 15 +++++++- src/server.rs | 86 +++++++++++++++++++++++++++++++++++++----- tests/api_server.rs | 88 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 189 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4251f..5a9bde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 disabled" instead of an unusable empty secret / empty signing key. ### Added +- Signed GET URLs (#129): dedicated `--signing-key` / `COSY_SIGNING_KEY` + (falls back to the API key) and optional signed `exp` expiry. - `cosy serve --host` to choose the bind address; a warning is logged when auth is disabled on a non-loopback address. diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 176b3fc..a9435c6 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -163,16 +163,21 @@ GET /r/og-image.png?d=&sig= - `d` = base64url-encoded input JSON (`{"data": {...}}` or a bare brand+slides object); max 8 KB decoded → `413`. -- `sig` = HMAC-SHA256 hex over `{template}:{d}` using the API key as the - signing key. Wrong/missing signature → `403`. Signatures are bound to +- `exp` (optional) = unix seconds after which the URL answers `403`. When + present it is part of the signed payload (`{template}:{d}:{exp}`), so it + can't be added or stripped without invalidating `sig`. +- `sig` = HMAC-SHA256 hex over `{template}:{d}` (or `{template}:{d}:{exp}`) + using the signing key (`--signing-key` / `COSY_SIGNING_KEY`, falling back + to the API key). Wrong/missing signature → `403`. Signatures are bound to the template name, so a URL for one template can't render another. - `{ext}` selects the container: `.png` (default) or `.webp`. -- Responses carry `Cache-Control: public, max-age=3600` for re-crawls. -- Disabled with `404` when the server has no API key configured +- Responses carry `Cache-Control: public, max-age=3600` for re-crawls + (capped to the remaining lifetime when `exp` is set). +- Disabled with `404` when the server has no signing key or API key configured (dev mode keeps unsigned POST only). Generating a URL (pseudo-code): `d = base64url(json); sig = -hmac_sha256_hex(api_key, template + ":" + d)`. +hmac_sha256_hex(signing_key, template + ":" + d [+ ":" + exp])`. ### Responses diff --git a/src/cli.rs b/src/cli.rs index f16e5c2..cd02a3c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -136,6 +136,11 @@ pub enum Command { #[arg(short, long)] token: Option, + /// HMAC key for signed GET render URLs (`/r/...`). If not set, reads + /// COSY_SIGNING_KEY, then falls back to the API key. + #[arg(long)] + signing_key: Option, + /// Allow bg_image/logo URLs pointing at private/internal addresses. /// Off by default: the API renders attacker-controlled JSON, so /// image fetches to loopback/RFC1918/link-local targets are blocked. @@ -380,6 +385,7 @@ impl Cli { port, host, token, + signing_key, allow_private_images, allow_local_image_paths, } => { @@ -387,6 +393,7 @@ impl Cli { let api_key = token .or_else(|| std::env::var("COSY_API_KEY").ok()) .filter(|k| !k.is_empty()); + let signing_key = signing_key.or_else(|| std::env::var("COSY_SIGNING_KEY").ok()); let image_policy = crate::text::ImagePolicy { allow_private: allow_private_images, allow_local: allow_local_image_paths, @@ -400,7 +407,13 @@ impl Cli { println!("Starting Cosy API server on port {}...", port); // Tokio runtime for async server let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(crate::server::run_on(&host, port, api_key, image_policy))?; + runtime.block_on(crate::server::run_with( + &host, + port, + api_key, + signing_key, + image_policy, + ))?; Ok(ExitCode::SUCCESS) } } diff --git a/src/server.rs b/src/server.rs index 8166076..7777f49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -191,22 +191,52 @@ fn constant_time_str_eq(a: &str, b: &str) -> bool { constant_time_eq(a.as_bytes(), b.as_bytes()) } +/// Canonical string that is signed: `{template}:{d}` (legacy, no expiry) or +/// `{template}:{d}:{exp}`. +fn signed_payload(template_id: &str, d: &str, exp: Option<&str>) -> String { + match exp { + Some(exp) => format!("{template_id}:{d}:{exp}"), + None => format!("{template_id}:{d}"), + } +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Parse and verify a signed GET render request. /// Returns the decoded input data, or an HTTP status + message. fn verify_signed_request( signing_key: &str, template_id: &str, query_d: &str, + query_exp: Option<&str>, query_sig: &str, ) -> Result { - // Payload = template + data so URLs can't be transplanted across - // templates (a signature for template A must not render template B). - let payload = format!("{template_id}:{query_d}"); + // Payload = template + data (+ expiry) so URLs can't be transplanted + // across templates (a signature for template A must not render template + // B) and an `exp` can neither be added nor stripped after signing. + let payload = signed_payload(template_id, query_d, query_exp); if !constant_time_str_eq(&sign_payload(signing_key, payload.as_bytes()), query_sig) { return Err((StatusCode::FORBIDDEN, "invalid signature".into())); } + if let Some(exp) = query_exp { + let exp: u64 = exp.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "exp must be unix seconds".to_string(), + ) + })?; + if exp < unix_now() { + return Err((StatusCode::FORBIDDEN, "signed URL expired".into())); + } + } + // d = base64url(JSON) — decode leniently (padded or not). use base64::Engine; let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -262,12 +292,29 @@ pub fn signed_get_url( template: &str, data_json: &str, ext: &str, +) -> String { + signed_get_url_with_expiry(base, signing_key, template, data_json, ext, None) +} + +/// Like [`signed_get_url`], optionally adding an `exp` (unix seconds) after +/// which the server answers `403`. `exp` is part of the signed payload. +pub fn signed_get_url_with_expiry( + base: &str, + signing_key: &str, + template: &str, + data_json: &str, + ext: &str, + exp: Option, ) -> String { use base64::Engine; let d = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data_json.as_bytes()); - let payload = format!("{template}:{d}"); + let exp_s = exp.map(|e| e.to_string()); + let payload = signed_payload(template, &d, exp_s.as_deref()); let sig = sign_payload(signing_key, payload.as_bytes()); - format!("{base}/r/{template}.{ext}?d={d}&sig={sig}") + match exp_s { + Some(e) => format!("{base}/r/{template}.{ext}?d={d}&exp={e}&sig={sig}"), + None => format!("{base}/r/{template}.{ext}?d={d}&sig={sig}"), + } } /// Treat an empty string as "not set". @@ -293,10 +340,23 @@ pub async fn run_on( port: u16, api_key: Option, image_policy: crate::text::ImagePolicy, +) -> anyhow::Result<()> { + run_with(host, port, api_key, None, image_policy).await +} + +/// Full-control entry point. `signing_key` is the HMAC key for `/r/...` +/// URLs; when `None` it falls back to the API key (legacy behavior). +pub async fn run_with( + host: &str, + port: u16, + api_key: Option, + signing_key: Option, + image_policy: crate::text::ImagePolicy, ) -> anyhow::Result<()> { // Blank values (docker-compose passes one when the variable is unset) // mean "not configured". let api_key = non_blank(api_key); + let signing_key = non_blank(signing_key).or_else(|| api_key.clone()); let ip: std::net::IpAddr = host .parse() .map_err(|_| anyhow::anyhow!("invalid --host '{host}': expected an IP address"))?; @@ -325,7 +385,7 @@ pub async fn run_on( render_slots: tokio::sync::Semaphore::new(max_concurrent), image_policy, api_key: api_key.clone(), - signing_key: api_key.clone(), + signing_key, }); // Protected routes require auth @@ -704,8 +764,9 @@ async fn signed_render_handler( ); }; - // 4. Verify signature + decode payload (also rejects >8 KB payloads) - let data = match verify_signed_request(&signing_key, &template_id, d, sig) { + // 4. Verify signature + expiry + decode payload (also rejects >8 KB payloads) + let exp = params.get("exp").map(String::as_str); + let data = match verify_signed_request(&signing_key, &template_id, d, exp, sig) { Ok(data) => data, Err((status, msg)) => return error_response(status, msg), }; @@ -746,6 +807,11 @@ async fn signed_render_handler( } // 6. Render first slide on the blocking pool + // Don't let a CDN cache an image past the URL's own expiry. + let max_age = exp + .and_then(|e| e.parse::().ok()) + .map(|e| e.saturating_sub(unix_now()).min(3600)) + .unwrap_or(3600); let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = 1.0_f32; @@ -782,8 +848,8 @@ async fn signed_render_handler( ( StatusCode::OK, [ - (header::CONTENT_TYPE, image_format.mime_type()), - (header::CACHE_CONTROL, "public, max-age=3600"), + (header::CONTENT_TYPE, image_format.mime_type().to_string()), + (header::CACHE_CONTROL, format!("public, max-age={max_age}")), ], bytes, ) diff --git a/tests/api_server.rs b/tests/api_server.rs index d1d754c..ff7eb52 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -18,6 +18,11 @@ fn start_server() -> String { /// Start the API server with an optional API key for auth testing. fn start_server_with_key(api_key: Option) -> String { + start_server_with_keys(api_key, None) +} + +/// Start the API server with an API key and an optional separate signing key. +fn start_server_with_keys(api_key: Option, signing_key: Option) -> String { // Find a free port by binding a temporary listener let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); @@ -26,9 +31,11 @@ fn start_server_with_key(api_key: Option) -> String { thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime - .block_on(server::run( + .block_on(server::run_with( + "127.0.0.1", port, api_key, + signing_key, cosy::text::ImagePolicy::UNRESTRICTED, )) .unwrap(); @@ -981,3 +988,82 @@ fn test_empty_api_key_means_auth_disabled() { .unwrap(); assert_eq!(health["auth_enabled"], false); } + +// ─── Signing key + expiry (#129) ──────────────────────────────────── + +const SIGNED_DATA: &str = + r#"{"brand":{"brand_name":"T"},"slides":[{"stat_number":"1%","stat_label":"x","source":"x"}]}"#; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[test] +fn test_signed_get_separate_signing_key() { + let url = start_server_with_keys(Some("api-key".into()), Some("sign-key".into())); + // API key must NOT be able to sign when a dedicated signing key is set + let with_api = server::signed_get_url(&url, "api-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_api).send().unwrap().status(), 403); + let with_sign = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_sign).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_enabled_by_signing_key_alone() { + let url = start_server_with_keys(None, Some("sign-key".into())); + let signed = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&signed).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_expiry() { + let key = "exp-key"; + let url = start_server_with_key(Some(key.into())); + let exp = now_secs() + 120; + let ok = + server::signed_get_url_with_expiry(&url, key, "stat-card", SIGNED_DATA, "png", Some(exp)); + let resp = http_client().get(&ok).send().unwrap(); + assert_eq!(resp.status(), 200); + let cc = resp.headers()["cache-control"] + .to_str() + .unwrap() + .to_string(); + let max_age: u64 = cc.trim_start_matches("public, max-age=").parse().unwrap(); + assert!(max_age <= 120, "cache must not outlive exp, got {cc}"); + + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + assert_eq!(http_client().get(&expired).send().unwrap().status(), 403); +} + +#[test] +fn test_signed_get_exp_cannot_be_stripped_or_added() { + let key = "exp-key-2"; + let url = start_server_with_key(Some(key.into())); + // strip exp from an expiring URL (even an already-expired one) + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + let stripped = expired.split("&exp=").next().unwrap().to_string() + + "&sig=" + + expired.split("&sig=").nth(1).unwrap(); + assert_eq!(http_client().get(&stripped).send().unwrap().status(), 403); + // add exp to a legacy (no-exp) signature + let legacy = server::signed_get_url(&url, key, "stat-card", SIGNED_DATA, "png"); + let injected = format!("{legacy}&exp={}", now_secs() + 3600); + assert_eq!(http_client().get(&injected).send().unwrap().status(), 403); +} From 60142b1516984cd3c1b592bc6a2231d3ad62548b Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 7 Oct 2026 14:33:27 +0700 Subject: [PATCH 7/7] chore: bump version to 0.5.1 (#134) Co-authored-by: ajianaz Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9bde9..8d6675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Cosy will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.1] — 2026-10-07 ### Security - `POST /api/render` hardening (#127): `scale` limited to 0.1–4.0, at most @@ -27,7 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`OutputFormat::encode_owned`), `template.svg` sources are cached by mtime, and remote images are cached for 5 minutes (32 entries / 64 MB). - `/api/health` returns the startup template count instead of re-parsing - every `schema.json`; `/api/templates` reads off the async workers. + every `schema.json` (restart to pick up templates added at runtime); + `/api/templates` reads off the async workers. + +### Docs +- Brand: logo symbol variants and 3-tone fan mark icon set (#125, #126). ## [0.5.0] — 2026-09-30 diff --git a/Cargo.lock b/Cargo.lock index 5be5fcf..db29ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,7 +443,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cosy" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index bca423d..aefdd78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosy" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "Content Easy — Lightning-fast template-based image generation in Rust" license = "BSL-1.1"