From f08a4a0808df0af17613ace74b673e42747be94d Mon Sep 17 00:00:00 2001 From: ztimson Date: Sat, 27 Sep 2025 00:40:24 -0400 Subject: [PATCH] init --- .dockerignore | 10 + .github/workflows/build.yaml | 55 +++++ .gitignore | 4 + Dockerfile | 24 ++ README.md | 104 ++++++++ archive/index.html | 13 + archive/logo.png | Bin 0 -> 12312 bytes assets/archive.zim | Bin 0 -> 85140 bytes favicon.png | Bin 0 -> 28935 bytes package.json | 20 ++ src/controllers/admin.mjs | 139 +++++++++++ src/controllers/archive.mjs | 167 +++++++++++++ src/controllers/views.mjs | 108 +++++++++ src/main.mjs | 65 +++++ src/services/environment.mjs | 19 ++ src/services/files.mjs | 133 +++++++++++ src/services/kiwix.mjs | 96 ++++++++ src/services/zim.mjs | 81 +++++++ src/views/admin.mjs | 452 +++++++++++++++++++++++++++++++++++ src/views/archive.mjs | 330 +++++++++++++++++++++++++ 20 files changed, 1820 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 archive/index.html create mode 100644 archive/logo.png create mode 100644 assets/archive.zim create mode 100644 favicon.png create mode 100644 package.json create mode 100644 src/controllers/admin.mjs create mode 100644 src/controllers/archive.mjs create mode 100644 src/controllers/views.mjs create mode 100644 src/main.mjs create mode 100644 src/services/environment.mjs create mode 100644 src/services/files.mjs create mode 100644 src/services/kiwix.mjs create mode 100644 src/services/zim.mjs create mode 100644 src/views/admin.mjs create mode 100644 src/views/archive.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..33a1d34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.idea +.vscode +.git +.gitignore +.dockerignore +DOckerfile +docker-compose.yml +docker-compose.yaml +node_modules +archive diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..d65db90 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,55 @@ +name: Build Website +run-name: Build Website + +on: + push: + +jobs: + build: + name: Build NPM Project + runs-on: ubuntu-latest + container: node + steps: + - name: Clone Repository + uses: ztimson/actions/clone@develop + + - name: Install Dependencies + run: npm i + + - name: Build Project + run: npm run build + + tag: + name: Tag Version + needs: build + runs-on: ubuntu-latest + container: node + steps: + - name: Clone Repository + uses: ztimson/actions/clone@develop + + - name: Get Version Number + run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV + + - name: Tag Version + uses: ztimson/actions/tag@develop + with: + tag: ${{env.VERSION}} + + publish: + name: Build & Push Dockerfile + needs: build + uses: ztimson/actions/.github/workflows/docker.yaml@develop + with: + name: ztimson/kiwixm + repository: ${{github.server_url}}/${{github.repository}}.git + pass: ${{secrets.DEPLOY_TOKEN}} + + publish-docker: + name: Build & Push Dockerfile + needs: build + uses: ztimson/actions/.github/workflows/docker.yaml@develop + with: + name: ztimson/kiwixm + repository: hub.docker.com + pass: ${{secrets.DOCKER_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db5f2ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.vscode +node_modules +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a48623 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM public.ecr.aws/docker/library/node + +# Environment +ENV PORT=3000 \ + KIWIX_MANAGE=/usr/bin/kiwix-manage \ + KIWIX_SERVE=/usr/bin/kiwix-serve \ + ARCHIVE_DIR=/data/archive \ + ZIM_DIR=/data/zim + +# Install dependencies +RUN apt update && apt upgrade -y && \ + apt install -y --no-install-recommends \ + curl docker.io kiwix-tools unzip zim-tools && \ + rm -rf /var/lib/apt/lists/* + +# Setup app +WORKDIR /app +COPY . . +RUN mkdir -p $ARCHIVE_DIR && mkdir -p $ZIM_DIR && npm i + +# Startup +EXPOSE $PORT +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -f http://localhost:$PORT || exit 1 +CMD ["npm", "run", "start:prod"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d401ba2 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ + +
+
+ + + Logo + + +### Kiwix Manager + + +A web-based Kiwix management tool to create & upload zims as well as manage non zim files + + +[![Version](https://img.shields.io/badge/dynamic/json.svg?label=Version&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm/tags&query=$[0].name)](https://git.zakscode.com/ztimson/kiwixm/tags) +[![Pull Requests](https://img.shields.io/badge/dynamic/json.svg?label=Pull%20Requests&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm&query=open_pr_counter)](https://git.zakscode.com/ztimson/kiwixm/pulls) +[![Issues](https://img.shields.io/badge/dynamic/json.svg?label=Issues&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm&query=open_issues_count)](https://git.zakscode.com/ztimson/kiwixm/issues) + + + + --- +
+ Documentation + • Release Notes + • Report a Bug + • Request a Feature +
+ + --- +
+ +## Table of Contents +- [Kiwix Manager](#top) + - [About](#about) + - [Features](#features) + - [Built With](#built-with) + - [Setup](#setup) + - [Production](#production) + - [Development](#development) + - [License](#license) + +## About + +Kiwix Manager is a comprehensive web-based management interface for Kiwix servers that extends beyond traditional ZIM file management. It provides both a powerful Kiwix library management system and an integrated file browser for non-ZIM content, making it a complete solution for offline content libraries. + +### Features + +- **Non-ZIM library browser + uploader integrated into Kiwix** - Browse and upload any file type through a custom file browser that works alongside your Kiwix content +- **Kiwix management interface for updating library content** - Full administrative control over your Kiwix server and ZIM library +- **Upload ZIMs from computer or download them directly from Kiwix library** - Flexible ZIM file management with local uploads and remote downloads +- **Create ZIMs from URLs (zimmit)** - Convert websites directly to ZIM files with customizable scraping options +- **Create ZIMs from zips / files** - Generate ZIM archives from your existing file collections + +### Built With +[![Docker](https://img.shields.io/badge/Docker-384d54?style=for-the-badge&logo=docker)](https://docker.com/) +[![Express](https://img.shields.io/badge/Express-000000?style=for-the-badge&logo=express)](https://expressjs.com/) +[![JavaScript](https://img.shields.io/badge/JavaScript-000000?style=for-the-badge&logo=javascript)](https://javascript.com/) +[![Node](https://img.shields.io/badge/Node.js-000000?style=for-the-badge&logo=nodedotjs)](https://nodejs.org/) +[![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) + +## Setup + +
+ +

+ Production +

+
+ +#### Prerequisites +- [Docker](https://docs.docker.com/install/) + +#### Instructions +1. Run the docker image: `docker run -p 3000:3000 -v /path/to/zim/files:/data/zim -v /path/to/archive/files:/data/archive git.zakscode.com/ztimson/kiwixm:latest` +2. Open [http://localhost:3000](http://localhost:3000) +3. Access the admin panel at [http://localhost:3000/admin](http://localhost:3000/admin) +4. Browse non-ZIM files at [http://localhost:3000/archive](http://localhost:3000/archive) +
+ +
+ +

+ Development +

+
+ +#### Prerequisites +- [Node.js](https://nodejs.org/en/download) +- [Kiwix Tools](https://kiwix.org/en/downloads/kiwix-tools/) (for ZIM file management) + +#### Instructions +1. Clone the repository: `git clone https://git.zakscode.com/ztimson/kiwixm.git` +2. Navigate to the project directory: `cd kiwixm` +3. Install the dependencies: `npm install` +4. Create the required directories: `mkdir -p data/zim data/archive` +5. Start the server: `npm run start` +6. Open [http://localhost:3000](http://localhost:3000) + +
+ +## License +Copyright Ā© 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing + +See the [license](./LICENSE) for more information. diff --git a/archive/index.html b/archive/index.html new file mode 100644 index 0000000..cfd35fd --- /dev/null +++ b/archive/index.html @@ -0,0 +1,13 @@ + + + + Kiwix Archive + + + +

Kiwix Archive

+ + + diff --git a/archive/logo.png b/archive/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..55ad032d7e7ca10faeb038d9ca09a25d01fa59a0 GIT binary patch literal 12312 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 literal 0 HcmV?d00001 diff --git a/assets/archive.zim b/assets/archive.zim new file mode 100644 index 0000000000000000000000000000000000000000..3aaad76354a98efb66e6e815319db2b34ad86a30 GIT binary patch literal 85140 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 C6`;TX literal 0 HcmV?d00001 diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..32aea5e7bd669e05c23861af1364e5c79225ea5f GIT binary patch literal 28935 zcmeEt^;=Y3)F`5aq$nVrLk!&^p@_r~f~ zefOvPFWl!Lp0m!{tJjXT_6b*it3ZfHg@=KGK?qTN1I562gaUrca328>cJXi;;NK%F z8C4k!jOtkY-zHeVGlQ8TR22i`BP#~R=O7G>UjXRy4hDuh9|p!A3HH5%l`;yEx-^^a2BK~Ob{$KIYtpor8OxuvdDN)Y(rJ)egt{@PrmfH7*^$eU{ z(_NH!{~E$Q8$9BcD`;e$I&PNH@kZK|hW?B((#{1C0kNhY$#Wp5^P(2t-o@i!@+OX$ zvR9YYtEmN02co4Fm^qsuHJ2bYkix_0r+p62ryRDHubvPC^n6G6WtRY&^q$4o)F$8C98vpdCKlEcF12%V+vs}^q*<011)^+Yv+Pi7x-PFIHeZ6+dk&2m~8 z$0tQ*8e2LRB?s^VpV&SaoW@|s9cwjuGAw*??3##SXKjs8fWahlT3=ZZk_r}(yizqvG7LF!-{g_N|^qhK$9~ZFR zKqa}MjdLNBSAu~Tn&O_GcbXIJa&f%O7i+ocDzEwe-f1mM>KL(Az$P0MKK;Y zK0#c(kAVf}5{kNz2ot_8xgvPE#%u;yw*1MD4ZKf~XMyS%N)+9MDk8Y|fz&AbHIWCM z^SOLY`V)Evn-R#Y-5*)Y&XxS^uav)Lrt8{0L7bI_kQ8XrI;<=oSUkal+JAJSi=>n- zF;N^~RzOXZu=$x;nW>*hZ&785Vgoce+A6*#E}?UVywdhltO*cjJsLr~(9Gi#kP+c) z${Ob6asNg=OVaxYATWX>j-kB_$foO5siu?vA~~Z^X!joixE8Zsm0K)k zaX@4hT&y^F2w-ov*BbU~n#CdAvzWNrSbt|pSlc`q2&VlSG~cq>{~9v4g{xgnJDOR? zkG(m>X4wA(KsB0*&IzM(WmD5L28ViVuw$(06J z)kw${j2t#z^b)g3Z$gdv?^P=OW<=TUfM$50J`iZwBMFRUiOB_3u+9h$oGIxa+b%Wv zDAEJaibfF@8;QSio@Q|aCy$obl>y#_#v54$8eU2E(J~y9BZfmR^7rY}xs9t0-E-x9 zP8C>3>FB~wPaRk=CLWOZ!}I*P_&i0y@Lpd}`Ko zFuFeCbcnuXdzqgkqewQ^0f0;Zkg@kUt^t4rX8szd79|4>qv?p4WFaueH;d!2QNAZN zKvMqo$EpX90Cvg1QUPy5vHOL2mLBER*p;LqJC7ewa@xRc#|4dNk) z4VGUQPbUWCn1{!EyIonN#JVJzP9yO3Mi#Ic!HWRjff9n8EPlmsih-sfA#BL*rm*h# zPVaZ_s)Fu|jfXM+fxw-mWnHa?$GSPlFCiJSt|9MCNf8r$H94he=VCa3wxm@qB7#ve zK*CJh_0}F(6F|p@Rxz^x`E1sK!D7f5~=PYIHVP%M3~MZV;nqS8Y(5^dJn zm(KP4ir@PAr_#Zs}}5Ghh|5vW;gjjzqJ7;aN;STi&(|O#{$9 ztPwW{u6t^=%w5)w1kiTr%{OQu$UwJWlT{ajR{=k+G*~@Spaej0!s#M6ORSs$|E0-#~<4kFwpt403$OT}!Y(cklAE6m6G!2Z`3C!Y1dDTch8TcBn zyfTXus0iA>HQT!?WsJ6Y?VBS)*mgU~QjSetoq@$wy$cA!H??X51OphyVY4)EQA0P@ ze)o;R{CJ2*odtf{M8Ri4JX^i?*IWFNsY5VU!5c*XP%S?Tez{xO$anE6ih>M^Zt-$O zJ6nTV&dj+O`#GMq-N<;5pQT+R)D>J{VqIV>*hk{EcAZB>7 zut=S`zZRF(N!+!jlK2H#@7bp}`=$L37h7T;YE#Yq0`e!Ujwe)Qb`Ahry-@y?c{Ic3 z#Ot?fi5!(?I3Haf;k@JR>1_(qfoFB7RQCgVm>-~Rg@AZy)OL}M)jX4OQ+vAsdV%9s z_slD+qh&8#79eCy3y<^w2RdNqNa zwJDV3w?7K(9g@ccaTMNn4*u+UW{d&w(ONQTdBxrQxxf(~{7m=vuwVTKUL`EjkcBV0 z`4*@401pti$9}jkqt!x*DMOV%hL)&^;+tp3vy?zoL|6|QEJ?7J7_Q$w zc<#g@?2^^Rd9h8YbnpZi&G_L&sCV`Tq4_!dXJ??ar9!E7NxZ`MZOL?F1l~lzvl}8| z`0`v$LFcCPqkS&AnpKvPfVQpCpJ-@c92aV!hB@Ebyh|C%GPD}|+dkih3uF&+EXJi4 zKoW^GgS3=vUW~RCyp;4cz~> zXo-YyOC;gnux%ZU^J4&LJUpo^#hhRQedZd05|_UxB7je)9A2Nx0r$|jscY9*?ac&kt8hQ0|6xJY$YxC!r4nCIopvf;U1=M3B!IS@> zO58W_f*$_0ua)umc327V^n`JF)!r=b%sJvjWRMf0^yc}}#@p;9#!Zt)`$B*11(l%z zMJ^A31&~}fjwqRAHzL`}Z1O$3=HT z(`U-icS8BRQ}94&zPj{x3-cMe7CHNLT|~qV?TBd&Kf7OzcLa)uSl<`|jX&&Se=CBm z2eatqot@iCEXcDRVK~aElwp5g^D)B7b164zm=gj%mG%W}G7xJQ1Do2QyBBt9X6af| ziygmw><@C$ULT^Ah?bQ1`#=>0oF9sDJuQ&Qjml7t)^5rnQGj}IF{@*BG9f_el9G*Y zi2~_9Cn4vR35Z`f>(Mg&7@bo-IInUfaf-6KVF6F=jfHMT@!rt3e)a$v!4ZVrvhxSkiUZ*F8$}=-{ZHrx|YqXDwLAoX)fInva<|hSGPLHbNTmP97 zB48p>bk~$%Anya^$JDm%%|irf#_h$Hs+szd`>%k+8PDedDo5cZY~8u zTJTGEj zl`7J8`iL_vQ`#QWB>_PA!XAu2zxQaTE3l;JG zU!b*xpc2%QX2fN?2gr?T-_S_AH;@5j){rHVzYnv_i%K9&UL+4>p(Gr$%E@N0KHHgP zeW9N%%??TBs{R{087*Vtx#m=FmNyxx1eLGY@-pVb4(8Y(DSxjAXl6NO8b>Un@wJn- zV?LOlGBwl~q1iA;)5nI+c@iuUaOn#8a^P!H%T9d*p2inu-DW991H9Z-An^06mF28zb+68Yy59p<9W6|2#F`dVpOcc|b9;L2?(sg(}@*H%3*1 zO6Z=uJZ_e+sT7xNUrwQNpg7;@)VX>L%*#E3G*)JifyViaQC{okyDxOkaYY5he<)@Z zHvV}{uA4X$-#_w^2|W72h2&-U?%V9dpZ$)hWR`r}{OwD*LU?9*|H|Hu_dX~+a z$(niPv&i*DCr1A7)*-}KLZg<}p(|o+M3aXgh{xwAh)kud3vOw}hX!Dok6A}6? z)1+XsNu7hC*WQs6EPrNTheI+V2?f7P`iCXsyx>??0s;1&5vAPUYrX@qp2vfK{!JND z8bRJNa49!`EOpj1((M3*!!sTNOgG z(=45;-jh5{Qf_u1A3gOlNS%`z^&-}vgZx~2u*B^*>GDwNy6s=DuIi|_L!a)A9_ny3 zxMeYw56%eHIDQx!aUe8PoGqu-e)#(J+?4I1 z0U+BfL?o)f=PN5p9IK6f0INn}a(D^YM-fh4oV|hEjz!cyTv(r8Jvi060`?;IJFo_n z3BV!o!4jXZ5&QMiEQo}63KweHGh&ztOuSckwmOnt%d8MuonPP7EzOEwetEs~C|wRp zNArBg$(N6K{%er_m4F`XkB%pfJIiJlrT>wqBUMOszI~>~J96EBu65A>9jbB8F{vuG zV|9~Pf;G%fV+o&2?TOMrIGz8opZyh{h0gw+ zkAWTx9-t28v6C5Gi8a}F^0?IW&NwrIO|8PH-$3c?j`=~mDNZ9+1DKpdi@uNV&owBk(* z4~tB0;%vl90gXs*aN@FA|BiMr$8*^Bm}( zM2X8}GL^q2&Z^l4`(bHIG&JyoHsvbv>~EQ~OmUfYwT{$&Y+}T0Jo5N?@a;n=*{=?A z-2pRy_Z(G5@XMre(5XGoD#_96;qc#|cp>~z`@0=I8S<^FmHa47WhHg}%8vPkqBtZy zB$;(^XY|Ua`19GG-_nXt!y0)~^4+t@E<9h+wE1)7cT8L2^w-c&AiXr?nOegtho$zK zrWf=RUAmLd2DCKE$Z~qJYNNWXP6LK ze$7@Z;_RM;m4uAJEK4V31gl9$`{I}Fba5l)zoMZIt3K1LUIg*`ikx>@XIe1v02Z*0 zbR_%_r+74*sqXQ^jQ%|*e6Ah4 z`V&hbaY!}|-Dm6bcc(s4wti9cKq*&-R5#{5WFS>5D$;(@Z|$Gy(%teo@yg8-12~p0+{>4juUlPo)W#o~j%lkvEd!-%?F~|# zxyE7UeuK}fZj8sJfC6p)XvduqPSPRxfvHTcyMOxB6CzHw3F-V>{WCE|W;ps4xh?`} z^UO;;Pq<>kd7UeYS$6>9uQjX@ggDz*BdOSilT%olP*MAzy@S=~<^rs%u+QIt_9@Mg z!KG%81{D133so7w-<58aLHnne*8#U*Xf_MlUF4ranywh$Yr<)>PYY%AVf~o44-f4y zA@BwSb!kh?OmwwV7L(>%JT5g7rGchUcz%Q}g>4uDYr4@ZLLEAiI1!8Bs94pI<@(9N z90z!Dv(sDB+CLFFC7?kBl@p8j?$f*gQB$NTbY4aEe&7@}WwiQaOD}l<_ z!?0jRc4Dj~W?h74%=T=0EZ(pFhmn7O)WV_!F@X?LoCb>Xi;t#4p_E7M{otio4@=3x{z8Dyozb6ypYxmr4T@t`#DNbSA)fb!u z95Archn1l+bGg*bHa;f^ONT1iT2cp|sl(haQq>WfWcEN~V;5qJQMsum4{aNN3$FrA zajiA9Vk;v`%~>B(VANSyc~}~x3681{2+rb~`?EkwKUdvEBu95(DudUIf)E7<>7$CW zKysApqj@1p-LBM&USNxc_EMAIh=Mo27jTi;n1hbb5Cx#2Z5G-4$b-D2+8{LnQoDZs zh6VE2tLHzm&^iZ;m$K@g{tI9PVl0D^#)4o#`M(GNQxbZm0mqQr|J(^;DHI`gPUZh! zcNJ{>SRrz_KVU)-q!s&36<@M_fCJR$Vq6voq&Wb`M>Bm*ry^{ja+e|rRDz4`$}t!v zqsNVcfiLYbA<#8>7cSQ*nN#pl>Q1o8X8_}BOX~#%aC8DeBA`}FFa@*!5qds?RTl!sJY_@y`(fX{o#X#XV{rPSnJTc83)KSn8mV1DIr0fOu;JfN zagg1qD8>t+|FLKxw&Y+qI%f>8eDvO2rBkc#eo+K+6zRt|zE7}_zJ?%>F1Af!chNGz zoL9KhR7G>Rhyr9;$IGTp3e9*P4ZB`c<}d?9sp-Ro>4Rg6px0p3l?i>i41)AkxVw<8 z*CGR{ynnq^s~t83`h+N-PBvPGkyMvkY{4=Pgq0V{lGMqLT2Z>kR}Y^;bf9xo%d{_~ zobmv4>!S@9r6`%rGUNXX;Mx4Eneh;q^jLi?1r-sPu0+JpNebi^rfnV|1KtSb??Gk5 z`^WP8M|JwF0-(`Rfi+EWPrty7LPgeW3|(3Xqu)bypU)x+UbUi~xkRw-*$J6-Ia~3R z5v1jT6!NZS>c8-~ZrVct3Ckp8txX7&-urKA=^_f^4m)gt#>0Q1SUA%1TEcA1PfNMS zxXv_5l@WsX^UcSzV19NfEb^g(`sjOr5RpX$KRb6ZZtIW{0*Ph7p8-^1cfqI@fEN;r zmU2V{ol{4udpu8yD2PJpcI}uGz>||~QSU|Fnve43%r6pQ@^l_50P(duRi^5UX0LF6cl~n@)fkWF?wh3}Td1?((V!^eu z9EmK`t3Kwg073=7>lcfBMQCOD=JoH72|5N^N9TtaBU?gg#Rt~kx16Nw#<@|5D{P!u zb_9}HLp*lI)WF;R?aEUkwW;DoJ0hzL!ff{`jfR)Jb07eL7iAl@Nmg#kpnwwevo!t? z)5Ov>J5|)!t~9Nl{s!T)E*)d#jJa?dWNSSNFKG&`V(FTj+!}4D1Gb|5G- z+rNSubu-tSLg-4weu-b0M`bcJPlyE6hv?XmLI(Vsn;1{+lTJW5E>C2kPQMMj^2UIY zvF!c378#%92`2kH_Zai{S={qi|H`mrGmIsgC$6p24pXgb6`@7G-Ah_&>OxfBF3%Lm z;PjR*VWaGI?MegY4Gkv1)<_%W_@PpIK6}|&{oOST8%MDJZM@W!##gnsQw$Kjt5#B@ zpHcyV;<;a3%Nn;WUd(~HP>7Xv7ZA8?+C}`jz%Dz>QfI_Wd%R&eUWdE7hAp}ncmG5< z+MWtBkWrY?Q43yd3^5$M{J}jj{c;`FRu^avp;AC;p9K&yLsA730yPYbJDxfe2*y6Q z*UFaIiK)1?SSM_Ij>FZQ7?cESsNZqs(1N#PKl$T9_O(owxhn-_@zS)g&> z!rn66=|>LU5)wwDHmhmSh7!sm1+I^5bI+TUp&^p(CLKr}RN7xR@Ie2N5!}DX<2io0 z7@RE^WYMMXki*OQaADu(VoS)9t!=TD;=tZ`PMy2(ZYpNCWKk3X%_Vmx8hzA!J^VFQ z`RVc=Zj5EM5u2M0ZO1P}Jis68NaKp|YfHeb;efM>npfG~xg_C%B)P8hDJT~dY|rQY zd^e_rT}t8kksT`UGGRV*pka3Ed<;+8H&xv_X*qI~ZD zc74b6Ay6pX8cXh=lqkafopj9dg6ovjSJ&POPb-@A*x=d6`By+~k%T7x7g&zdL70~> zqss;{OYUiEm6g*?L{_v1UTXB4s{n!d3V%n--ZJGYTvHjr**gw12fBXLX#|6ZnRW5S zGb-$uAb9B4Bb%e;!zUz_8_q>eZ%k(t+>4&6vH66z3cQEO*neDjgCGqP6+<+-IAxf5 zMfuufk4<~z`$o{mSG>Y6?>F3B`2xa%5q{r#y|ukzy?v^tB+i#RXm^F`u4#{$$Gb9T zqlO@$4w$`XPL*%X6ZwGkT)s9)sob&!CR*DbF`@sqHXa1-QEE!pNKNWu&K~i#lbdpz=Epy^JTM#%Dg8omll5 zZpL=-CyTZ*d}Btfgn*h0f8TbK77laQ7Kodop}8{(p4#alMN@sn(k4~1>5vgYf(p?% z8GR4S`NIfXMr<$gOb%G4Pkn|I(3zj6FlT#XRETn(0A;C^QQju*N!NNEIUG8R@3W0H z5ee`S)Fe+(rv>9(b*3r{L{7UikY;pH`fH(KsQ}1dEcd!iC7yXwY+8uwrpOfLypEMy za=}gcrn7@-luUOH>z^jcX>Q4$66qFpqt_g|xbhsW2>JbqS5@4EAXwr?Zy z&3+FphTD4$ln6}}=&O4-(=*!%Kq7v)Ic)M&twLU@VNVF5FaJe<3bFIo(sQh?0&2S? zln_5`v}EGvzr~!RlZ@!ggR}Ey!oCUVU;iU4QD9D+h$<2?a4d0{G|zkEmnb9IuOlC?RLl=s zoXOo8UWK8ltFg4@q=qO(GBJg+DSbK!iUc!xQ%7hli#?7uS@-cOiiGm9D&_)`>hj3A zeL)IOwE2;}@Cb4F_zyVe=5~T+!z&m?lmL;QgJB7P2r8;yMeq81zlw<-YfLF)g-s8+ zUG-6Rg|(Wk{`^nyDOModKbK1+g28PS-VkN8m_63@bOEUC8n3^iRx7y>r$6U^b`uoq z>TX&@RAHlL`(iAOs4nkeg^j(Z;uyygjF0yu_uZqShqANaBKO!ez9fo!1!#6@pDM-A zMXf$W3uLMJ2pXtWTt-x0)#cLK+iXr5kr)gshMaRaTABw5#p<6iHTTvXCGQ+&=9BA^ zor~Pf`f%hyrB|iUd?D4wuMHmv+sH}GPsB9T6>=Y1E7(R>D(Q{@chJNfYku$r%pk4A z8H(IrcFrO1S+B-y>ZPm6@g;Zy7u=T4=0k9W?}~QjEf>G*xg~CMF0H7_3q~Y{#4}#P zGxAOKuW1b(+5xU}=kE?|9&#|+-hQ5eCwSg;@;!YQ+E7S?^#%A&5ChNn7c8;HqC4u3VoRmGbEgk90DrY5YHm`8*uLEAy<#9&~ zde^b2Y(8V<%Obw%!&%sPt$LK}$Ysm32|W^l;g@$_|7q3DhEz%#3|fcq1$q94DK~wq z%cJGCku%UAPZO!j6CTDqbQeq+AeFXY%03A%#&Eq&{Vm$|T;q7J>lrqXe_u4bhFCSb ziv)rg3C`cBjO~pf$n84F>vSD$Ad#BUUc;v3ld1h~MS{U4K1Ykjm=0^b)#aX83g@M# z`Y^Jh7iOm3OigE&WL0MbyaMCw0u|)k)-tYC<6%I zFYC_an851AXiZb!WoxalZEldEi2l}OwvmssBHmA%YNW2MSiki;GO-qAbjy$=E=yWE z{TMhGYVxgBs?kIS6=pwZN|MM{5P!QYwb6*Nt0Eq%s2 z{=;0Mw2KGlq)pxh2Vr;Z+vom z7HJ%c(+>Sfb-2;)QbYEuaW;wi5c2^7h64IfyoT8q-!|(IK2L-5>@S z5(wqleji^|&mEXa1{5rP1aC6`_f6>2Ujlq*^&VS~t$J$OIhN^fe-sUaf#dh)HvXnK zL5xpD=#3%iz4U7@(nT5_#SqBnWnzgs%1 zTf+sTfFqTpPR+#Da4wZR16YULS?C$Fv6b-Awt7Agdke)sEzh^qRhi?Eg0;InLt!&| z>HY`Sb>zAhcP#Jc+M9qfR>AMop?Iz^uS{;QENyupGm?6K!X9qN0JX6*YntL3jx~3O zuJs?n`uOEFlI?uyvp{jd1xDcu4)@B$aiX6BM6fk0G#r9bi|a_rv4gAr4qr39-`@Rc zjrv5BXz`$s#zAnqPkl+?5k&lwr(T?I&7H2>DoSuZqAcr{`S-))g(2}Z@M{SCO5-Tp zD|+e%=Z>-*YZ^W6R8s400Mz1mkS^d@g_eb};&FOTNl!b^X__JPvB;HN>~X}ntfV&w9F zT(VaAsU@EO4q<_uhy$1A*4&MIS+H&W>#I3a9f6Z11p2n6cQ<}{%{@K#a|6Iz<&&N3 z_GeELr3pXDQDKs*YVz3X)((A`?}8CQV~61Awk2f3?{a%OY-Tp2*!lyCT$h?-6_b2! zgL0-F7qm;QbE?^G`!m(gyE5m=?Ke}42d-jE@}qSS$q%^D&s5`|vFi#c?Pnvlgz_kb zzsTBaQ@tfao^iEWQ}4cLEEErn_HaGBMjlv+Pg{Ma9ziCJmdW)rs!UIKb*{|&{d;7L~KE}EFKGp>qdtzDDr-C-p!-O(Q1*pBA z+zZ(L=eAF+9ZuNiaEFER7iw=lvHc;wiR8rwnuCj7!6JUx6?rj?iu|QAq8}&H{{;T| zoibD_*|p!4@i&)|d53D3YGz^qOw9$DYFHX(An^)&%Ca87x9%CgSnG?5ddh3c9!NP9 zl~HfXCO;uCAv|K`D1E+Z;$PoJ*OFG2dT^cx#wV1C&=mYQ8JaIB?ssiXxALxS;GHd9 z-&j~xa(a84G@BgtFXlp?RD=12>cYMs#l)j1f%Ri5TX^87x)IefV@h~Hpo^4YzQB() zZhXWLxAd-2#A~Cs+mDmBcUsNJMkkX)nyeQknAMKA+sx~7?e(l7i0Z=hMg7t|+NRVw zabFz0?#zSuz}4VYl5OqIk~XKh+*&S(Km!e|IEkZz$d}?Z{Za`yJy*gbKI9d?8D31rycaz*&CdHy}B>rNeLFp?Jm*(RbR2+|4)5b($tpUTEhs z7j(@pD06C8rco|GxS-`sg@ldAV?F0&9*xZwSU&YHRVZ5p*rd=s5|v;y;p zjWEt#=R^qIH6e(3Fxtbi1cUpQ_T0Dk%UDg&U$;Kpm)6~(B*hnG_P+2xR!+MAoEbF; z8K0$5`Pa=6Pwn3#seS4-)gQKvf{qGQLtb~ z?c~p1R0cs={~(D~?W~PLE4QcYFlCPj`biz}?g!k7Mj91A55W)$A9O5bivPNG&rTTc zO17dW`KI@f{C;|GKbK%g4xBKlF87LHg>0}a#jZD>z$AGkcHQ=^g;s&>PyYwEbls;^ z<&Nn+I=}_h2W9<^gfjZYbTcRHi&6$8W%f5j1}EApI_AT$2fc5;p)++i>hkBumRlPb zc6#u=3jghNX)cpIHL!FsIk5_+i}0Brr@QsNy{#zXs`p}9li)xWRNVZ0g7iMTd8x4e zOf(=e^~uTKnRQioXM#ECmYLIAhZ&M)u+lE-Vk{Iv65Kg`d}8Rbl;di`yxtm5Rz$8GSG6^r+0o zZ-ZYJ7g3CxsAB|A|31;q09D`llzlzRJJ$N|an+k|ZTgbP-fd%Gm9IkL-}C_$)B*<0 z^`}dgYJrN;wD8RI60()7s#O(B`ETfo+fb~e>BgW0=C+?|D8|IARo@XJr|<7KVo9n_)!b^^ zCw^sZDTF-v=?MqF{NT*YJ2AolE#oH^_#D!4c&$c3vioaKAPx=x;KfG8-Q8*k!+nrC zFddSUG3&3ultlqOP3fa-p`VrZT>BHo`_?bc^?Y(82Dn2q5qA7Vy=DGakBqo8*RifK zmImJUp5P8&y;iX`;jDz>RXkw-T3$q)O)1N_HwJhSKJ_)L_}uH*22DHEF)u!uHK0Ur*|{Oh=ee-4B>?2 z4DAh{yYYp`7+;Z5Xk{$an zkzPa)ev>J!V~mkww1b1GjWWF8E+ z_=(did~3V!oZpk?8ZA+h-6z*KTL*s6wULO82ak6d?t z%;zJ2F9mdjgv&yW+W690jIx&4v09el?$?8tF<#OCdbRJ7%-;_8au+?Yb$m3CGSV`f zg^m+n5D0jPIXCl{s`!5tkRq7W#Pj-3<@lgjL10Rl&qM9nmf_v4sgdU)IVxfy#SgzNs3gA2Tu-Iz&d*^(Jv3rAe6LEhmaUiB4V;_LLMFl; z&~eZ%3M<@!&r1X@(`6Y*-E#`cZtwlncUzhFA@6SajP*h=IK7W5?uK5PCOBPK)>ru-~CcIvj2wa*AX-{MZkLaVZm(74T&5WscQ&I%XUnboawTtl- zJ7Xm*MBJJEqR_Kz)NFqnbvhWT{44p1ylZ9nO$wWrVCaB{q^-+uOZ84O8qM8Z`VhxQ(Yf|8RA(Syl5#EYuOx@r}^j2jNf8Zrd^)k~Ken;~zci42EjE zxb1oGH<8evdNEXpl9j?xo~5qL1xg{J>O7e5!YN)mFfpnr5~dsUtH5%_lJ~OCHI? zK21`hQ&L~4WSOPcp4FUmRvvj9aFIKMj;;2>v|TJ;UvDmw&80QeGIL;L!jWk}Z;_nO zQ$c7OCaKiKzSf)YCmI|0o@o>_W_5CiX4v%OsqX8AkD;;LC?qu@rS7Vg7l+^83B@6_ z#c%TFUBUi--z9*)tn#tQ2l4fF#mXaNh?Do|o4PF8!yusl`BNGAPU^utAK%n@#&Snl zeC6@ClkZFhu%2Dlj{*`m`MQI8;Ym7tpuEeQj>5WTG$)o8>Q586$6KdUjw{m1*L~M9 z!4_y8mmHBzGt|tX=I0+I?hy}Z8MhxobFbIkUSj^@;C0tQG!%h$o?j40BIemh4*#rr zyeibDw$v5)s(B+$B~koUO=mk=G1dd`^7W^1?+88z4GOxGrI+2uku{6_Bo)00K}uz5 zl4*O9Nr`U>;gUTX?8#2xGA6_K#bLaejqL*&PV_H+GbWSU3?A#l!0VrS{X=q;X2Tbt zEuQqJ#lUTut{*Rt^yRd8*NUUr>bW@9)Gu;LXuHZhUKUQskVv~vviJ>Bmi+C$-@QqQZpf)$RQn`3@Kj2M_vBb{##8x=+6z>>QfFA-7OUDoUEX_rF^;^; z1G{UDfsBN`XOehbGKQIJt<4cwB$X9{&kbNDKG!()jfI9(r>=z&Ixt{v-JS6NP8q=D z^sKqiW7+a-5>RQ7kDkrKKQZb-Iia=V@n%onv8*PRwvI$L^fE@`vn4tQ_s8mca2CrR z(NegReZ|)iOy$j60W22f)w_@Ocvq$>Z)!^p9JVzg42N?=a>CrUbV?Q*pYoWg2JPQH zIOIlYX59tMP3|*Ec^m6-GN;+q*?vh@;0Szisw}HsfOVGzU=uS*Q5t7w0|HB`@w0{f zq3L1<&m~yDeL8KX0LZcSmWm>P3Akj#7i!a6xXhH5q7pT4;U1OIB^>$)o%- z0lnAH#+gTfDuAhhdRgtTje9*pF|t1!_{LI_aVX^SsVuaXmzh~#O|&@+nuFyh^r&Y= z$1Y*s*E_42$#5YR+~zoeS=P4{c|d}82Cs&Y9uD`CsFJPXCQYo2)(Jf7$&qd>_ByUK zw+Xr&hz62DZN3*pxBf>n(L0}6Pfb)JE8^(Mi14GH4eJq=!#3ZJVUo(jM_0f%rgL=W z76aeSij{@f9;eqP8mY-?#rmlaQ;-6iGs*h9D;())JEN(aY9ItelD9QlJgXF5@Eel0 z3>nQiaW7B>nEr@(U*NHyx^SbQwtRvb->ee6Ubx>0MeD_vTk!%i?pRV$2AFCm7yv;%^G2-f&c@Es zzp$o!)gO5@w+y|l>$XOtu1R0h-+Sn980!6-8?FRbYg>S3RIDB5FuPP&9>H^6R5q=eoHHH{ zWx&%K!DYx{+iFTUt?AI+ zt>n7>27L*@mEvBg6<6UVv2)=!`%MTmpoa{cmHsGs7eP|UMEz&RToAkJOB2n zbjDhF9vJmOOe^rIr(DF#J>+7I^l)vZ5%pZcW6zvoz(Z#Olh8WRS})=A`%5T^zi}3Y zLvr#+uMVNjj+3{(=KJ5eF_kUACa)r{EGbyPBOlz}oVy;{HfrRxsd37xll)MQ@RrW0zn)w5YHh_V}2%F}gXo8Z*rR(n+Bx{OUr`LS&xe_h#stPUN zhZIVd0eR72iqo9=)Rl~zP(}_aI@PH2b2OBsVi!@T!v|bwKqTQWvb_<3r5|D96V*?G z8C7Y%L*E&_4*hYoFyBWv)8al~d$;b;O8@(;3;Z<|o;DyhY=^ne@~g_w->UT-ORh;YC>5CPb%cvOObrIEmyh z6)%_mFd?X0sS0j~g0Qsthhf(7*DHS*T+%4Ud(yb)dfpc69=_t0RaZLb?*Npqy0r!E zi%$cdPVauua_ z(I&KJp&BYY!kC5X-+HxX&K5YT@`V?Gsn?EJUMRl;_`K{JD$(dI|Ci(|oKcSAm}1q^ z>tjaginAA2jlfLS#c^oR9Z~w))xLW@06+)r5pbG#ol~6p&77>0Uzr+~5Z9x@EhTkVzaTvZnf&E(z2@MOtBNIIpZX)X_#R_g^VYbN`+gOQWoikNDEmL{eN|kP zQP-~^7^KpT62bsOccTK*I78P^Ln9?6B}ho;&?O<#-7s`_cZbACOC#sueZQM?acYYJhZC!%P6!AEukUKKRjv;2|^CIFN*Iz zsF@CPqGP_g+bF_UGr}nZLXi03%o(KcM)#)3(OBV-VUSGbqtxVUha0}z^6{3Geo!U2 zY&_c^jYKQu?BxKEkAd5k8dv>=FvgLA#Me^RtCR>sOP+&plmjPmIgj(>K6Bhd>6#MT zxemO%XJ;p0#4*=*CJ)yf|CPqH)C0Ed6#f8#kg};{6qVxJbOyC~DmX?(sm!)UyvE+e zHbAb^0k~hI5s3LhK)$61W=c{?ZcH9-h#mlEL6pWM7zjptFSOFk^jzK9V`A|I(RUpx zidKVv0o91h;s;$al~Gi~zc|n-`oMHsrmn^bWnVz2@}mDAMezs=AJb+K(4@(DFtA#e z^DZ;am0nL5wl}+`#wbhai$2c!y}2p3y@dqdN*I1z-YTCmo+WxNlp5=wO{c#Q;3BBrwziwPtjaR;QVIo^ zW}@=qNeeRaMTlQ$?h&*U?8Z-^?=#m_b>xrZaiY(en;a1lvYef&XaTtyCftRNZ3B7y z_+mNe^mK7KK5G)A>@X@ux-;Yc5nt|dDOR;Ye#B(p7V1jwz848}MxNRi#_VC9C4zP( zYO>zFxY3Rd+7?Zo{szcx*-S|pMKTSlXd6}|H1sX zY5eb#Z*F_>YIJ99(#*i(nx{xA_pKCTz?|KoA>3C&U<6%(IQrH`YmG=N8?d@~<&E{? z<;l;E08ysIDRVe1t9kK931>8Zz;y^WwrSTzktp(G(<3v>Uwi$rFDSD@SwF)XJ>bPL zRlydLbMq3;BdczeuSE-P*=gtEUjO^|)HxGtAsP(qY61D+SsguDb5aJ`Ftw%lBMGhX zJt+;QhGyHxZo}6;(q6>V=Tz1~P)wnq#qiDFBiuXrOvLDj90t|4Rltc607QSk3< zI@pFf@Bv0kWHKe4pPQ-NcP%>Je-dKi1T8fJjNd>GwnH;4*N(46d`yAwBtx&Rs!1OO z!!xc)y&1SlsZQ;qv|RDRE;{>Cf=*bxRoJRS-T{83IkyJnOwgN6N^V2#>%-GA^tyLN z5&r-QUcPinG;Zg-0W(hNjOqDEPPM)=rnQX6JJrm+GB3`X>*u9*XXY@64^mJ;;BXVh zh{@GqJ3HwC2#`h>LE=HyGNOv8ZrI`Vv`@>hIF~27mf_ZL-OzFlMXnB7xKVhRQCh-N zHN$>*yDrYL2ZZcx$1mE=%aFOEJ_PSiiTIBDD#wgmoQjsFg{yD(-*wW6?ay z^beW~)}`}~3BuHf5E}$6QQSr0aGOI}H2v%R^*v(@Af~rBtqA@~UodpnsLRF|@#&PH z8JguG+1KfS=dC+jHIV;Ggv=LxI(Q!K&mRn72MWUW&^dJ0KL-l-x+3GeiFz{K71J3X zJJ|DvtX54NiK_N&$S40PjD>SrtcY$rlR5i zy~WhuDaM|q(nR}kgt{Y!R{2B$0MwnVA~9&q$;Z{Tj#C*HlaMY#fT+1q!;Oy3)|M9r zuyBQRV>HyM*G;}=|pLHxXgd`13CQdKL(9S*li@S*083zc#jshYxIh>we zqAkqrszuCKabjTJ&#lbAU@OBoVu>Y~A74hxC0LPbpT8Ye&T|y(`-SQO#tefTs8}d~ zA2+S7fGfo}f z>SFz7Sc}o3y|1UdL%Fbg%hLd?Czu*P8JvIIprOIJS*EM+`y0D91$_S7C$jQY^dFR* zJ~rE6R=QuJ5o`6~ zIUlxl7zAxRjol>01i;%PIyB$CD5+;bQM}t+pfxZ}Psd~#<#ijwm9!Lq9(S@s&*py} zZp8RU?cbW1AOM1S_{`co(X~MAv6f-m)miOBW6b`SY=ETB`fsib?}x@K?J%}}jnYjQ z7%;?t&&3JzfbsHM`g2@{&o!hW~ueAH2SEWf6e5FR8g~#vzhJtN34H>?IgUv>thq-7wy#OvkUK+6D~< z2+6$qPoGaL9VW}|M!tHms=$lTrl+`WQD~Eu(CZ=Cf6jrY18m12sN=Dmu^QI-njYYg z7RmjeVSjj}25{M}0S+dt*;xF#-cL!E={nI5+a**MCJPKIH;G3V91WU_?7sa*x??yq zSmgd(^r>1%3vfRbQeIxjwVP2(PB+>~L)zde&2j1>M9y&mh&bPBqtRIq}bfYQV}ePTGhTnP?38rwv}8eU7RI~|y5 zHWtt-$9Mta)tcLzZ6T$a)xst`uMfT?C8tH zKEB%lG8S8(WnK)-eIgnG|4VmLqxeoS%4v6LAF?6sc+HoV9q(`)PbTCke|!03HQe_D zveb$3U;?MW8Y+th##d4uP5+tuusn!Mt>0K&00YxXDE49H#Fc?hfrCpdErz7uHKv!f zQV4Xa;}U$5sdp7s{qPutG%#%wlIjcn`Y!8Qf=g8|Bl=Fo1b{4hX#(dpF1U&Mlq5tT zaOx|e?JC*ihUGce6N#KSN?HnE%y^a!Wa5ku!Jp92rahyr+u`}0U;Mz~Km24Ud{K=6 zGmG`9WSjmwvz|;xoy1hJJ`vwzlKu|t@rG;S1yLp~`0z z!+a=={4HiP90P0ALW9f?n1ktHu8hJS{xYKA@xpvnc|*xB0ZuJLxjr%@fVC79C75Jv zY+_L`daDi35Z!yUQ6oIhlnsOu8rjN#IZC>RP-5XCQ6)qDS=8K+R*5i-le?>HzKf@X{Ursgh%`Hf5FXXcOp|oy7y|7U9 z+@jMtP!%oRCfW<|A_+O!p>*rgNrGuUdQkGc)Eu3Efwir!RWSkpaLsAVKMv|z^s5kv zrZ=QF$bWD+c!@hTP{fac3IFx*99qSnSfCc_Qk8?LRYVHrem$?}J>K7Mum0`|NUv86 z=N^LqTYr2X=7sHi>1-@JVZ-Co;y;G7g}H1JuLP6UwXbE;oJ#qV~7_W3| zqY$HGaT15q=+~8`N^_y~J6-8jKj_W5$M94!6Ue063^)O}9Pn1#9Vld8EXswykH~cp z^6})pSp|q zn#{#Q`L8R9^;+%v-K+lE{ER!Moaf9Qxk{Du?8oTlN$VRl#~b}>#r796i;fr%*AJJH zauf2s8K_QAmWk5laSJvmx7*Sb?LM2Wg*1nq>~$*Pxk?OrFXPvyt1H5}V-q_W-teRd zQHEU?my#5R9x>=cvJ-gJ9p54%9?45qa3&3znUAZ|{O!4>Tg=6qh7H6LS%jAi+IF26GY;8g36(Ha>m(pc-k-PN>h z3M;oa)wlU?#cmyxadL{y(7^-b4!V6dW?Z59p55l2{X`n`_0SH$KhOxWq1;a}M6^Nu zwOwg_HJt>+wznt_j+MFLw+yRE;G2`p3RXZnop%Lb{=mG106z|LzWv(wG?OA_~L!6#VVj@q1m)Kjp@hZ^;C z4p4vPGj@X&YgyH&O_v4yH-%z7&cPdl{xwyUtZqEtesr#B3a?{J*Y&m6Q;i~Jw0ZNIq{V5g?K?*8)b~6~>W(k8+do-K*06`M>I5wZ z<`#!<2F=M@ek;11oO$;znqbK1H-={lQnmGe>mW})y2$NFq;yvaUw^Zaag;JxwFeR3 zCVX@BJcD!WY9X|Qf5OrdM?Q_X$~>J*3m{ENE9!_uc7o^FmLyA9#X%1-b3&di2v(>6 zz^DkPBTQ^p`^g#J<#J7TNhkpr%!XS87XYn-D5KOO_K0L!q zxUwZ8r6(xis$b6+4pLm>QDN>j#ke8&gTS#CJ{kkSvyhQAJ&1oXa`^e6iV+2=y&YnP zD_Tq&eR4|?NjTeM1)8|BS>nN!*x-r3K@aYgz6F8-SR{&1{zz4{j=upefb`q}#uqMd zfcs*XD}a)|5xP(DSqVPfQD`)5*@yr_y^qkKUioTr?~pmsT+Dpaqrv3e0J{=Q8<7A^ zZ>56#0no*%E$8!s6P@9;AAkQ9J_)iHX2KMmAz@p6EWY}9d7BW%i#XA|28iBRNi_n* zuMka+bdLEN(up8aXu=1kh*qz%6K&2+}proHkS+5FRG6SLzdr95$wp2uosFt6l! zUD`N_o0ABDZ-WdxxC*}O!iN6d^2N9pWqsxyQFqT;auxcj4o8-_Y>-75t*qufPU|*& z!qd55g~cBQ#g)oGdICrxM70rdB<}7Z+xZGxq%WyvXa(v6wp05a53+x`R@Wyve$=Bn zKng)S$46G{S5%~K4vf8I>ne5M*w`3hN>k-4XqG*tz=E? zI>Ci`MMc`?tV-HdVID3NM|(n+k#~7>LOpDNd|BGz2%28sNTY6pv}a52+U_Gnd;duL z7_XIHSt42lT+H8wdCbNipoW$|tE?e(BAuQTdL`L%2L1(6he-wNw(}1HS>!;@=$F%f z*%Q><+x-iE4X?7$b_nhI}EhjY~Z@lSW+p>&!yA(|;L_MDtXejK4|ZsK(XXa|=;u9~kkP1I)vw z&T>GQB&8bcldaQ5)@2zQ&*@# zHkkd2U@G8|?L6B;1C%02Ma0%z=7kS9+dV14JxKaF5s*^Mnt0||;gh103*U}BKHh4k z|2F)~S0r+E`cxFKtOmP99uR+FDF9wZ-$Q#%nTSnR8J^@wvJu>N4o(bq6|sf{4|v zNq8-;*sh#Pa#vGY2h-Uc(>beyDC*k^E-!*tWdUwa>f+FSD9ujd;RQ9x+kZ{q-T$JiZ@>Gl)-g^ zG&vBs*XxI9*w(CCyoV-_CcfE^@8;bwt@*W1Bi1FC&(5>w^diVfkxj)PMSTg{wcy*B zV3GJPH(y1J8dL*Ba>&UON2`)ZpVy3WMXo8;S6P0~ru%)E&t`4^n%n95)|rxwla zM#!CMCt@EijIO=f+fINpsNs}Obi4>zRnepDO|Jnwq8nrs@2il_{y+qvljDoK3r?5f)*YqtO%uPR&)z$950Bof++}ZP9Lu zx7U|3BSBrfy#91oQucX4d-e=of&tg+T0J;5XKycX7l*e3GqrFwc^|%@1FvE5Ya*rR z<|h_kZ^((xBPY@ zy%|f=Wx3j!-LSp3^TIJOdlu4$4=x2(Ci*8V^Vc5t_i5jQ8;nnx^rV{W+VMg2M=K{( zDuCDh9M6>!&>=$1b?KQaz*m~BrPaBM9iOPWLWvs+tOPhDXM2g+I5DIxh)J*Psjz{B zM_UU%z)SH%x%UL94N{5PFNr6C-u9yjGY$`RtDSmomSatOO=JQp*% zf>7ubz|R2fxb5I01R)0J09^FP?m!Yc-c56I;pC}XLONJIx?x;}) z+u3F*^NU*)L#4Pl@Y7}oz}s!P{!D|y**Fh}c1hCvS?dh{XEcq`9*9~>wLf8!HI7lf zB?dX^AI5_L@@|@{0X=zzq#j6oMHqt33|GP8nIxD3lBQ7=;i+-baV6N%4A^MfF-f#A zliP!Fa!aIs=`p5uJbe&cc=Lycql>K>eAX=E$HEo_S;w*yTkYq|AE*7&%>Bz|h6m$G z5E|6Kuscm05Z(52no_;8nn*scGLmf=56~~T?cYs5PM?YV1}?)of8%xKDsu={C-QEE z2EL$yjnCEK#?onzkdiP{=iE7-O)gmc+eNK%I;iV8Z%igJ~=Jz(!(Ux}|PJbc-j&Vklp|Mp0`Cscnh>7y+GPh9PX4F^75rjShCVAp3sP59J7}l? zBY_BqmyU|ge|f&>a8kB%GZ8Gc)G9X(o{) zWnzIapq{m~q-sH7*VQ}MVAUt`Gi}Z0aF!P~t-{KrsPXzD_d%KH0(-%b@XwfyQFbGP zdG8Q>tmhf9`}db0p6HO@f0oe{0$ta4~@UD?7zDbZU1Z%o;hF- zFmhuwSl8^V;_x_f8w)AYzD@iIvWUD(Y-#g$t_dx8^|WI2g#PKNL7I(bLc3}>CL&R< zN+!%>G9ajcx6)HRK49_fLRM{`_WB@CoHbvpbYz+a&NqYI= z)cGPb?rK>91Q)uq)I9rGbRQw@X-2A!i5gCbf1RYioqKfrH>Tg&Dgd@gPcUCuTvu#C zdcgi0aM@Su<7o+ab@8}-RR7>1UPjcn$(^=@T84c1RB$wKJBx8q0`_5>&uwI_3kQeJ za`#juL%cr$BlB$$4@3`wO+s%GDv$u&lB2UMxe>~#L~qZJRoL=R%WTKyx9Q})q6%4l z@Z)R6m|D39ce_}Fc!`^hvjtttCbvDiiHmz15Yy6DX|T~n_5%4|-Qn?!N!kaw@%lPg zN()%X(H>~?caMc{<{`Yn9R#10yNK{0YwV{lg_9C1@A6#Wp!dxzm&Ot;J049U_$0gk z+J*Gq={JUAxB8VrL7mG7K;{Xi*tU7g*))Cf!o9^Ml@jUOCtIKp19y@7kc z!n4Wr;tDGaOwC0i{T!aNg0;{aPxiL63=UcHLz2~g3A*#=%9c~#%_#LJZB`_Un7FN0 z6GhT^!siGEE565~Dj>6XC8MGL5{YQ#cuGvH*6qH*oPt-liNE6^z7#booD4MD*tsI! zx;kSToW(+6@{_BZbc2R>G-9X^SU^m)djaR)yeBu%g%Xy4Qo2g96|!EgKOcPJ-`vIv zw(d$2e?K5@I2dP~gOfC)vLO7PTE29~3tuA&pWhLP8(rqkC^EkYubY6pK@5aem3zp~ zskWe>infp}j;ZGfyd&`P@QjOhVZr=?e+ssMRI}y5)>YgQL@8dZM<*@Jqb7@KBHHn* zk9=T_2){C14&CV-GjeIo#+*3D74YR&OV}GUT60!!jWt;vO>cKU4HqzGCWTj$wFC4F zPA+iGY!0$MV7~;f!rEx9!Pg8f?W5T0#3B4R6`i2H6Je2g+*}m7`caCc23BCRJ9G5H z`id~r#1l&WUaT?2ubhxW3#+Rl?7Th~BCg1Bq_ozn zmot{QpG3Goxo^!|(*b&w$6z0BjEo1R{?@9A!ym%-)J;?xS~MO1>g`E0v9RA*RW<>7 zHMP58@`FBw{P0U@L(IuW6#5Fq!LT-ab$w!Rc~c)!g{Lvi4-$%0tdT4j=MLI$elOXd z;EjnCqlz9lJZL5-@6i3Tz+1Hesw<^RI6?$Ka^s~Jsm|VYGpP4Vh)UIwi@6J&2rT}X zEZ=_!ek)nE=GXrxg1yW^RT+P>o94I7aM158GbI?{@UrF4vfT_clV014-iOcBIZ07s zZvmd;Suh;qx6J${naKRaA;%6WQFa5;0)SfRRt-;cdOfcSR$9+8+eRp|`#oH_R!%E7HA`^;8k zwmsC_%zz3QW}0i^Ul?^Mv$*rvo5@?(apEfQ6O9;_gXJM)+wDLU z%P&F>jrDL(zc!F8#pt(v_rNOnY)h&H?qi4gSnft5EFzwD`sUu79IS1oh!~9!5h{7xa?I*&WYh1E zKNYPHRMgoWoDTNxA2b)uzWRO~W>$~;8k0Fkq5Xer7+|3Oc+5k1(Z@URV#xl z_}p0^aLn-yK&!un$q^3~Y&@y?uWDJ{`oGh0$I@R8nU&t|Sg1Y}ra7u!+FTLup`#BO z)bZ{HvCVCc2$~}wx;##{JF!da5VKM#Bc;9Rk72mtin3J_ICc#svyqIx@qbLFbFeba z`|6<-&Luye-(P0U_rK9&45ljEp0U>q8&Nr)r*s!ZjVsRxxRX1Qnh#c1fO1DM6^2Gh z8=8dzJROZ$)s#-4+&FBYs89~uWy2u)zV1m)7P9S{cFm2J-8Yc_=#5Z{@45bKrCW6eI)uWa@^^ zgG}9Y|M}hSM8;>G-VyjmLC#DqyM2Zf_+2zZ{)0g} { + const filePath = req.query.path; + if (!filePath || !filePath.endsWith('.zim')) return res.status(400).send('Invalid ZIM file path'); + res.download(filePath, (err) => { + if(err) { + console.error('Download error:', err.message); + res.status(500).send('Download failed'); + } + }); + }); + + // Download endpoint + router.post('/download', async (req, res, next) => { + try { + const { url, filename, restartKiwix: shouldRestart = true } = req.body; + if (!url) return res.status(400).json({ error: 'URL required' }); + + await ensureDirectoryExists(config.paths.zim); + const finalFilename = filename || path.basename(new URL(url).pathname) || 'download.zim'; + const destination = path.join(config.paths.zim, finalFilename); + + await downloadFile(url, destination); + if (shouldRestart) await restartKiwix(); + + res.json({ message: 'Downloaded successfully', path: destination }); + } catch (err) { + next(err); + } + }); + + // Upload endpoint + router.post('/upload', upload(environment.paths.zim, true).single('file'), async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ message: 'No file uploaded' }); + await restartKiwix(); + res.json({ message: 'File uploaded successfully', files: [req.file.filename] }); + } catch (err) { + next(err); + } + }); + + router.post('/zim-zip', upload('/tmp/').single('zipFile'), async (req, res, next) => { + if (!req.file) return res.status(400).json({ error: 'No ZIP file uploaded.' }); + + const zipPath = req.file.path; + const tempDir = path.join('/tmp/', `unzipped-${Date.now()}`); + + try { + await ensureDirectoryExists(tempDir); + await execAsync(`unzip -o "${zipPath}" -d "${tempDir}"`); + + const { name, welcome, language, description, tags, favicon, creator } = req.body; + if(!name) throw new Error('ZIM name is are required.'); + + const entries = await fs.readdir(tempDir, { withFileTypes: true }); + let contentPath = tempDir; + const directories = entries.filter(e => e.isDirectory()); + if (directories.length === 1 && entries.length === 1) { + contentPath = path.join(tempDir, directories[0].name); + } + + await createZimFromDirectory({ name, welcome, language, description, tags, favicon, creator }, contentPath); + await restartKiwix(); + + res.json({ message: 'ZIM from ZIP created successfully' }); + } catch (err) { + next(err); + } finally { + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }).catch(e => console.error(`Failed to cleanup temp dir: ${e.message}`)); + if (zipPath) await fs.unlink(zipPath).catch(e => console.error(`Failed to cleanup zip file: ${e.message}`)); + } + }); + + router.post('/zim-url', async (req, res, next) => { + try { + const { url, name, language, description, tags, regex } = req.body; + if (!url || !name) { + return res.status(400).json({ error: 'URL and ZIM Name are required.' }); + } + + const zimFile = await createZimFromUrl({ url, name, language, description, tags, regex }); + await restartKiwix(); + + res.json({ message: 'ZIM created successfully from URL.', file: zimFile }); + } catch (err) { + next(err); + } + }); + + // Delete file endpoint + router.post('/delete', async (req, res, next) => { + try { + await deleteFile(req.body.path); + await restartKiwix(); + res.json({ message: 'File deleted + Kiwix restarted' }); + } catch (err) { + next(err); + } + }); + + // Restart endpoint + router.get('/restart', async (req, res, next) => { + try { + const ok = await restartKiwix(); + ok ? res.json({ message: 'Restarted' }) : res.status(500).json({ error: 'Failed restart' }); + } catch (err) { + next(err); + } + }); + + // Info + router.get('/info', (req, res) => res.json(config)); + + // Health + router.get('/health', async (req, res, next) => { + try { + const zimFiles = await getZimFiles(config.paths.zim); + res.json({ status: 'healthy', zimFileCount: zimFiles.length }); + } catch (err) { + res.status(500).json({ status: 'unhealthy', error: err.message }); + } + }); + + return router; +} diff --git a/src/controllers/archive.mjs b/src/controllers/archive.mjs new file mode 100644 index 0000000..359d792 --- /dev/null +++ b/src/controllers/archive.mjs @@ -0,0 +1,167 @@ +import express from 'express'; +import path from 'path'; +import fs from 'fs/promises'; +import {ensureDirectoryExists, upload} from '../services/files.mjs'; +import {environment} from '../services/environment.mjs'; + +const router = express.Router(); + +export default function archiveController() { + const archiveRoot = environment.paths.archive; + + // Security helper to validate paths + function validatePath(requestedPath) { + const normalizedArchiveRoot = path.resolve(archiveRoot); + const normalizedPath = path.resolve(path.join(archiveRoot, requestedPath)); + + if (!normalizedPath.startsWith(normalizedArchiveRoot)) { + throw new Error('Access denied: Path outside archive directory'); + } + + return normalizedPath; + } + + // Create folder endpoint + router.post('/create-folder', async (req, res, next) => { + try { + const { path: requestedPath, name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Folder name is required' }); + } + + const parentPath = validatePath(requestedPath || ''); + const newFolderPath = path.join(parentPath, name.trim()); + + // Check if folder already exists + try { + await fs.access(newFolderPath); + return res.status(409).json({ error: 'Folder already exists' }); + } catch { + // Folder doesn't exist, which is what we want + } + + await fs.mkdir(newFolderPath, { recursive: true }); + res.json({ message: 'Folder created successfully', path: newFolderPath }); + } catch (err) { + next(err); + } + }); + + // Upload files endpoint + router.post('/upload', upload('/tmp/', false).array('files'), async (req, res, next) => { + try { + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const uploadPath = req.body.uploadPath || ''; + const targetDir = validatePath(uploadPath); + + // Ensure target directory exists + await ensureDirectoryExists(targetDir); + + const uploadedFiles = []; + for (const file of req.files) { + const targetPath = path.join(targetDir, file.originalname); + + // Use copyFile + unlink instead of rename for cross-device moves + try { + await fs.copyFile(file.path, targetPath); + await fs.unlink(file.path); + uploadedFiles.push(file.originalname); + } catch (err) { + // If copy fails, clean up the temp file + try { + await fs.unlink(file.path); + } catch { + // Ignore cleanup errors + } + throw err; + } + } + + res.json({ + message: `${uploadedFiles.length} file(s) uploaded successfully`, + files: uploadedFiles + }); + } catch (err) { + // Clean up any uploaded files if there's an error + if (req.files) { + for (const file of req.files) { + try { + await fs.unlink(file.path); + } catch { + // Ignore cleanup errors + } + } + } + next(err); + } + }); + + // Rename file/folder endpoint + router.post('/rename', async (req, res, next) => { + try { + const { path: requestedPath, oldName, newName } = req.body; + if (!oldName || !newName || !newName.trim()) { + return res.status(400).json({ error: 'Old name and new name are required' }); + } + + const parentPath = validatePath(requestedPath || ''); + const oldPath = path.join(parentPath, oldName); + const newPath = path.join(parentPath, newName.trim()); + + // Check if old path exists + try { + await fs.access(oldPath); + } catch { + return res.status(404).json({ error: 'File or folder not found' }); + } + + // Check if new path already exists + try { + await fs.access(newPath); + return res.status(409).json({ error: 'A file or folder with that name already exists' }); + } catch { + // New path doesn't exist, which is what we want + } + + await fs.rename(oldPath, newPath); + res.json({ message: 'Renamed successfully' }); + } catch (err) { + next(err); + } + }); + + // Delete file/folder endpoint + router.post('/delete', async (req, res, next) => { + try { + const { path: requestedPath, name, isDirectory } = req.body; + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const parentPath = validatePath(requestedPath || ''); + const targetPath = path.join(parentPath, name); + + // Check if target exists + try { + await fs.access(targetPath); + } catch { + return res.status(404).json({ error: 'File or folder not found' }); + } + + if (isDirectory === true || isDirectory === 'true') { + await fs.rm(targetPath, { recursive: true, force: true }); + res.json({ message: 'Folder deleted successfully' }); + } else { + await fs.unlink(targetPath); + res.json({ message: 'File deleted successfully' }); + } + } catch (err) { + next(err); + } + }); + + return router; +} diff --git a/src/controllers/views.mjs b/src/controllers/views.mjs new file mode 100644 index 0000000..9cb7375 --- /dev/null +++ b/src/controllers/views.mjs @@ -0,0 +1,108 @@ +import { renderAdminPanel } from '../views/admin.mjs'; +import {getDirectoryContents, getZimFiles} from '../services/files.mjs'; +import { environment } from '../services/environment.mjs'; +import path from 'path'; +import fs from 'fs/promises'; +import {renderArchive} from '../views/archive.mjs'; + +export const viewController = { + adminPanel: async (req, res, next) => { + try { + const zimFiles = await getZimFiles(environment.paths.zim); + res.send(renderAdminPanel(zimFiles)); + } catch (err) { + next(err); + } + }, + + fileBrowser: async (req, res, next) => { + try { + const requestedPath = req.query.path || ''; + const currentPath = path.join(environment.paths.archive, requestedPath); + const normalizedArchiveRoot = path.resolve(environment.paths.archive); + const normalizedCurrentPath = path.resolve(currentPath); + + if(!normalizedCurrentPath.startsWith(normalizedArchiveRoot)) + return res.status(403).send('Forbidden'); + + const files = await getDirectoryContents(currentPath); + res.send(renderArchive(currentPath, files, environment.paths.archive)); + } catch (err) { + next(err); + } + }, + + fileViewer: async (req, res, next) => { + try { + const filePath = req.query.file; + if (!filePath) { + return res.status(400).send('File path required'); + } + + const archiveRoot = environment.paths.archive; + const normalizedArchiveRoot = path.resolve(archiveRoot); + const normalizedFilePath = path.resolve(filePath); + + // Security check + if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) { + return res.status(403).send('Access denied: File outside archive directory'); + } + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return res.status(404).send('File not found'); + } + + const stats = await fs.stat(filePath); + if (stats.isDirectory()) { + // Redirect to directory browser + const relativePath = path.relative(archiveRoot, filePath); + return res.redirect(`/files?path=${encodeURIComponent(relativePath)}`); + } + + // Determine how to serve the file based on its extension + const ext = path.extname(filePath).toLowerCase(); + + // For PDFs, images, and other web-viewable files, serve them directly + if (['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.txt', '.html', '.css', '.js'].includes(ext)) { + res.sendFile(normalizedFilePath); + } else { + // For other files, trigger download + res.download(normalizedFilePath); + } + } catch (err) { + next(err); + } + }, + + fileDownload: async (req, res, next) => { + try { + const filePath = req.query.file; + if (!filePath) { + return res.status(400).send('File path required'); + } + + const archiveRoot = environment.paths.archive; + const normalizedArchiveRoot = path.resolve(archiveRoot); + const normalizedFilePath = path.resolve(filePath); + + // Security check + if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) { + return res.status(403).send('Access denied: File outside archive directory'); + } + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return res.status(404).send('File not found'); + } + + res.download(normalizedFilePath); + } catch (err) { + next(err); + } + } +}; diff --git a/src/main.mjs b/src/main.mjs new file mode 100644 index 0000000..3495767 --- /dev/null +++ b/src/main.mjs @@ -0,0 +1,65 @@ +import express from 'express'; +import adminController from './controllers/admin.mjs'; +import {environment} from './services/environment.mjs'; +import {ensureDirectoryExists} from './services/files.mjs'; +import path from 'path'; +import {startKiwix} from './services/kiwix.mjs'; +import http from 'http'; +import archiveController from './controllers/archive.mjs'; +import {viewController} from './controllers/views.mjs'; + +const app = express(); + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({extended: true})); +app.use(express.static('public')); + +// Routes +app.get('/admin', viewController.adminPanel); +app.use('/api', adminController(environment)); +app.use('/api/files', archiveController()); +app.get('/archive', viewController.fileBrowser); +app.get('/archive/view', viewController.fileViewer); +app.get('/archive/download', viewController.fileDownload); +app.get('/favicon.:ext', (req, res) => res.sendFile(path.join(environment.root, "favicon.png"))); + +// Kiwix proxy +app.use((req, res) => { + const proxyReq = http.request({ + hostname: '127.0.0.1', + port: environment.kiwix.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: `127.0.0.1:${environment.kiwix.port}`, + }, + }, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + proxyReq.on('error', (err) => { + console.error('Proxy error:', err); + if(!res.headersSent) res.status(502).send('Bad Gateway'); + }); + + req.pipe(proxyReq, { end: true }); +}); + +// Error handler +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); +}); + +// Start server +app.listen(environment.port, async () => { + console.log(`\nšŸ„ Kiwix Manager started on http://localhost:${environment.port}\n`); + await ensureDirectoryExists(environment.paths.archive); + await ensureDirectoryExists(environment.paths.zim); + await startKiwix(); +}); + +export default app; diff --git a/src/services/environment.mjs b/src/services/environment.mjs new file mode 100644 index 0000000..ad24051 --- /dev/null +++ b/src/services/environment.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const environment = { + paths: { + archive: process.env.ARCHIVE_DIR || '/data/archive', + zim: process.env.ZIM_DIR || '/data/zim', + }, + port: process.env.PORT || 3001, + root: dirname(dirname(dirname(fileURLToPath(import.meta.url)))), + kiwix: { + manage: process.env.KIWIX_MANAGE || '/usr/bin/kiwix-manage', + serve: process.env.KIWIX_SERVE || '/usr/bin/kiwix-serve', + port: process.env.KIWIX_PORT || 8000, + }, +} diff --git a/src/services/files.mjs b/src/services/files.mjs new file mode 100644 index 0000000..acc1960 --- /dev/null +++ b/src/services/files.mjs @@ -0,0 +1,133 @@ +import path from 'path'; +import { createWriteStream } from 'fs'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; +import multer from 'multer'; +import { promises as fs } from 'fs'; + +export async function getDirectoryContents(dirPath) { + try { + await fs.access(dirPath); + } catch { + // Create directory if it doesn't exist + await fs.mkdir(dirPath, { recursive: true }); + } + + const items = await fs.readdir(dirPath, { withFileTypes: true }); + const files = []; + + console.log(dirPath, items); + + for (const item of items) { + const itemPath = path.join(dirPath, item.name); + const stats = await fs.stat(itemPath); + + files.push({ + name: item.name, + path: itemPath, + isDirectory: item.isDirectory(), + size: item.isDirectory() ? 0 : stats.size, + modified: stats.mtime.toISOString() + }); + } + + // Sort: directories first, then files, both alphabetically + return files.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }); +} + +export function upload(path, zimOnly = false) { + const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadPath = req.body.uploadPath || path; + try { + await fs.mkdir(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (err) { + cb(err); + } + }, + filename: (req, file, cb) => { + let finalFilename = file.originalname; + if(req.body.filename && req.body.filename.trim() !== '') { + finalFilename = req.body.filename.trim(); + if(!finalFilename.toLowerCase().endsWith('.zim')) { + finalFilename += '.zim'; + } + } + cb(null, finalFilename); + } + }); + + return multer({ + storage, + fileFilter: (req, file, cb) => { + if(!zimOnly) cb(null, true); + else if(/\.zim$/i.test(file.originalname)) cb(null, true); + else cb(new Error('Only ZIM files allowed')); + } + }); +} +const pipelineAsync = promisify(pipeline); + +export async function downloadFile(url, destination) { + if(!url.endsWith('.zim')) throw new Error('Only ZIM files allowed'); + if(!destination.endsWith('.zim')) destination += '.zim'; + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP error! ${response.status}`); + const writer = createWriteStream(destination); + await pipelineAsync(response.body, writer); + return destination; +} + +export function parseSize(sizeStr) { + if (sizeStr.toLowerCase() === 'infinity') return Infinity; + const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }; + const match = sizeStr.match(/^(\d+)(B|KB|MB|GB)$/i); + if (!match) return 100 * 1024 * 1024; // default 100MB + return parseInt(match[1]) * units[match[2].toUpperCase()]; +} + +export async function ensureDirectoryExists(dirPath) { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +} + +export function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export async function getZimFiles(archiveDir) { + await ensureDirectoryExists(archiveDir); + const items = await fs.readdir(archiveDir, { withFileTypes: true }); + + const zimFiles = []; + for (const item of items) { + if (item.isFile() && path.extname(item.name).toLowerCase() === '.zim') { + const fullPath = path.join(archiveDir, item.name); + const stats = await fs.stat(fullPath); + zimFiles.push({ + name: item.name, + path: fullPath, + size: stats.size, + modified: stats.mtime.toISOString() + }); + } + } + + return zimFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)); +} + +export async function deleteFile(filePath) { + await fs.unlink(filePath); +} diff --git a/src/services/kiwix.mjs b/src/services/kiwix.mjs new file mode 100644 index 0000000..7a109ca --- /dev/null +++ b/src/services/kiwix.mjs @@ -0,0 +1,96 @@ +import {exec, spawn} from 'child_process'; +import { promisify } from 'util'; +import { environment } from './environment.mjs'; +import { getZimFiles } from './files.mjs'; +import { resolve } from 'path'; +import fs from 'fs'; + +const execAsync = promisify(exec); +let kiwixProcess = null; + +export async function startKiwix() { + if(kiwixProcess) return; + await synchronizeLibrary(); + const libraryPath = resolve(environment.paths.zim, 'library.xml'); + return new Promise((resolvePromise, rejectPromise) => { + kiwixProcess = spawn(environment.kiwix.serve, [`--port=${environment.kiwix.port}`, `--library`, libraryPath]); + kiwixProcess.stdout.on('data', (data) => console.log(data.toString().trim())); + kiwixProcess.stderr.on('data', (data) => console.error(`Kiwix Error: ${data.toString().trim()}`)); + kiwixProcess.on('error', (err) => { + console.error('Failed to start Kiwix process.', err); + kiwixProcess = null; + rejectPromise(err); + }); + kiwixProcess.on('close', (code) => { + console.log(`Kiwix Serve exited with code ${code}`); + kiwixProcess = null; + }); + setTimeout(() => resolvePromise(), 2000); + }); +} + +export function stopKiwix() { + if (kiwixProcess) { + kiwixProcess.kill('SIGTERM'); + console.log('Kiwix server stopped.'); + kiwixProcess = null; + return true; + } + console.log('Kiwix server is not running.'); + return false; +} + +export async function restartKiwix() { + try { + if(kiwixProcess) { + stopKiwix(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + await startKiwix(); + console.log('Kiwix service restarted'); + return true; + } catch (err) { + console.error('Error restarting Kiwix:', err.message); + return false; + } +} + +export async function synchronizeLibrary() { + const libraryPath = resolve(environment.paths.zim, 'library.xml'); + try { + const { stdout: libraryOutput } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show || echo`); + const libraryBooks = new Set((libraryOutput.match(/ID: \w+, Path: (.*?),/g) || []).map(line => line.substring(line.indexOf('Path: ') + 6, line.length - 1))); + + const zimFiles = await getZimFiles(environment.paths.zim); + const directoryFiles = new Set(zimFiles.map(file => file.path)); + + const filesToAdd = [...directoryFiles].filter(file => file.endsWith('.zim') && !libraryBooks.has(file)); + const filesToRemove = [...libraryBooks].filter(file => !directoryFiles.has(file)); + + for (const file of filesToAdd) { + console.log(`Adding file to library: ${file}`); + await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`); + } + + for (const file of filesToRemove) { + console.log(`Removing file from library: ${file}`); + const { stdout } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show`); + const match = stdout.match(new RegExp(`ID: (\\w+), Path: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`)); + if (match && match[1]) { + const bookId = match[1]; + await execAsync(`${environment.kiwix.manage} ${libraryPath} remove ${bookId}`); + } + } + } catch (error) { + if (error.stderr && (error.stderr.includes('No such file or directory') || error.stderr.includes('cannot open library'))) { + console.log('Library file not found or is invalid, creating a new one.'); + const zimFiles = await getZimFiles(environment.paths.zim); + for (const file of zimFiles.map(f => f.path)) { + await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`); + } + } else { + console.error('Error synchronizing library:', error); + throw error; + } + } +} diff --git a/src/services/zim.mjs b/src/services/zim.mjs new file mode 100644 index 0000000..a45ac9e --- /dev/null +++ b/src/services/zim.mjs @@ -0,0 +1,81 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { environment } from './environment.mjs'; +import path from 'path'; +import { promises as fs } from 'fs'; + +const execAsync = promisify(exec, { timeout: 3600000 }); + +function escapeDockerArg(arg) { + if (arg === null || arg === undefined || arg === '') return '""'; + return `"${String(arg).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function escapeShellArg(arg) { + if (arg === null || arg === undefined || arg === '') return "''"; + return `'${String(arg).replace(/'/g, "'\\''")}'`; +} + +export async function createZimFromDirectory(options, contentPath) { + let { name, welcome, language, description, tags, favicon, creator } = options; + const zimFile = name.endsWith('.zim') ? name : `${name}.zim`; + const zimFilePath = path.join(environment.paths.zim, zimFile); + if(!favicon) { + fs.copyFile('/app/favicon.png', path.join(contentPath, 'favicon.png')); + favicon = 'favicon.png'; + } + + let command = `zimwriterfs -w ${escapeShellArg(welcome || 'index.html')} -I ${escapeShellArg(favicon)} -l ${escapeShellArg(language || 'eng')} -t ${escapeShellArg(name)} -n ${escapeShellArg(name)} -d ${escapeShellArg(description || 'ZIM Archive')} -c ${escapeShellArg(creator || 'Unknown')} -p KiwixM`; + if(tags) command += ` --tags=${escapeShellArg(tags)}`; + command += ` "${contentPath}" "${zimFilePath}"`; + + console.log('Executing:', command); + const { stdout, stderr } = await execAsync(command); + if(stderr) console.error(stderr); + return zimFile; +} + +export async function createZimFromUrl(options) { + const { url, name, language, description, tags, regex, maxHops, waitEvent } = options; + const sanitizedName = name.replace(/[^a-zA-Z0-9\-_]/g, '_'); + const zimFile = `${sanitizedName}.zim`; + const zimFilePath = path.join(environment.paths.zim, zimFile); + const tempDir = `/tmp/zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + let zimitArgs = []; + zimitArgs.push(`--seeds ${escapeDockerArg(url)}`); + zimitArgs.push(`--name ${escapeDockerArg(name)}`); + zimitArgs.push(`--headless`); + if(description) zimitArgs.push(`--description ${escapeDockerArg(description)}`); + if(language) zimitArgs.push(`--lang ${escapeDockerArg(language || 'en-US')}`); + if(tags) zimitArgs.push(`--tags ${escapeDockerArg(tags)}`); + if(regex) zimitArgs.push(`--include ${escapeDockerArg(regex)}`); + if(maxHops && maxHops !== '') zimitArgs.push(`--max-hops ${parseInt(maxHops)}`); + if(waitEvent && waitEvent !== '') zimitArgs.push(`--wait-until ${escapeDockerArg(waitEvent)}`); + zimitArgs.push(`--output ${tempDir}`); + + const containerName = `zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + try { + await execAsync(`mkdir -p ${tempDir}`); + const dockerCommand = `docker run --name ${containerName} --rm -v ${tempDir}:${tempDir}:rw --shm-size=2g --cap-add=SYS_ADMIN --security-opt seccomp=unconfined ghcr.io/openzim/zimit zimit ${zimitArgs.join(' ')}`; + + console.log('Executing:', dockerCommand); + let { stdout, stderr } = await execAsync(dockerCommand); + if(stderr) console.log(stderr); + + try { + const resp = await execAsync(`find ${tempDir} -name "*.zim" -type f`); + const tempZimPath = resp.stdout?.split('\n')?.[0]?.trim(); + await fs.copyFile(tempZimPath, zimFilePath); + return zimFile; + } catch (fileError) { + throw new Error(fileError.message); + } + } catch (err) { + await execAsync(`docker rm -f ${containerName}`).catch(() => {}); + throw err; + } finally { + execAsync(`rm -rf ${tempDir}`).catch(() => {}); + } +} diff --git a/src/views/admin.mjs b/src/views/admin.mjs new file mode 100644 index 0000000..f5ac0d8 --- /dev/null +++ b/src/views/admin.mjs @@ -0,0 +1,452 @@ +import {formatFileSize} from '../services/files.mjs'; + +export function renderAdminPanel(zimFiles) { + const totalSize = zimFiles.reduce((sum, file) => sum + file.size, 0); + + return ` + + + + Kiwix Manager + + + + + +
+
+
+ logo +
+

Kiwix Manager

+

Manage your offline content library

+
+
+
+ Browse + +
+
+ +
+
+ ${zimFiles.length} ZIM files + • + ${formatFileSize(totalSize)} total +
+ +
+ + + + +
+
+
+ šŸ“¤ Upload ZIMā–¼ +
+
+
+
+ + +
+ +
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+ +
+
+ 🌐 URL to ZIMā–¼ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

Maximum number of link hops to follow (0-10)

+
+
+ + +

When to consider page loading complete

+
+
+
+ +
+
+
+
+
+ +
+
+ šŸ“¦ ZIP to ZIMā–¼ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+

ZIM Library

+ +
+ +
+ ${zimFiles.length === 0 + ? `
+
šŸ“š
+

No ZIM files found

+

Upload or download to get started

+
` + : zimFiles.map(file => ` +
+
+
šŸ“š
+
+
${file.name}
+
+ ${formatFileSize(file.size)} + Modified: ${new Date(file.modified).toLocaleDateString()} +
+
+
+ ${!file.path.endsWith('archive.zim') ? ` +
+ Download + +
+ ` : ''} +
`).join('')} +
+
+
+ + + + +`; +} diff --git a/src/views/archive.mjs b/src/views/archive.mjs new file mode 100644 index 0000000..b21d1c6 --- /dev/null +++ b/src/views/archive.mjs @@ -0,0 +1,330 @@ + +import { formatFileSize } from '../services/files.mjs'; +import path from 'path'; + +// Move getFileIcon function to server-side +function getFileIcon(fileName) { + const ext = fileName.toLowerCase().split('.').pop(); + const icons = { + pdf: 'šŸ“„', txt: 'šŸ“', doc: 'šŸ“„', jpg: 'šŸ–¼ļø', png: 'šŸ–¼ļø', gif: 'šŸ–¼ļø', + mp4: 'šŸŽ„', mp3: 'šŸŽµ', zip: 'šŸ“¦', html: '🌐', css: 'šŸŽØ', js: '⚔' + }; + return icons[ext] || 'šŸ“„'; +} + +export function renderArchive(currentPath, files, archiveRoot) { + const relativePath = path.relative(archiveRoot, currentPath); + const pathSegments = relativePath === '' ? [] : relativePath.split(path.sep); + + // Updated breadcrumbs: šŸ  / Archive / + const breadcrumbs = [ + { name: 'šŸ ', path: '/', isHome: true }, + { name: 'Archive', path: '', isArchiveRoot: true } + ]; + + let accumulatedPath = ''; + pathSegments.forEach(segment => { + accumulatedPath = accumulatedPath ? path.join(accumulatedPath, segment) : segment; + breadcrumbs.push({ name: segment, path: accumulatedPath }); + }); + + return ` + + + Kiwix Archive + + + + + +
+ +
+
+ +
+

Archive

+

Manage your offline content library

+
+
+
+ + +
+ ${breadcrumbs.map((crumb, i) => ` + ${i > 0 ? '/' : ''} + ${crumb.name} + `).join('')} +
+ + +
+ +
+ + +
+
+ + + + + + + + +
+
+ ${files.length === 0 ? ` +
+
šŸ“‚
+

No files found

+

This folder is empty. Upload files or create a folder to get started.

+
+ ` : files.map(file => ` +
+
${file.isDirectory ? 'šŸ“' : getFileIcon(file.name)}
+
+
${file.name}
+
+ ${file.isDirectory ? 'Folder' : formatFileSize(file.size)} + ${new Date(file.modified).toLocaleDateString()} +
+
+
+ ${!file.isDirectory ? `Download` : ''} + + +
+
+ `).join('')} +
+
+
+ + + + + + +`; +}