From 22b2e19431a4c0790557b4062534de73ba58e4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=ED=83=9C=EC=A0=95?= Date: Thu, 5 Nov 2026 10:28:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 16 + .editorconfig | 20 + .env.example | 13 + .gitignore | 14 + .nvmrc | 1 + Dockerfile | 52 + LICENSE | 9 + README.md | 128 + app.js | 63 + app/package-lock.json | 1603 +++++++ app/package.json | 1 + app/src/App.vue | 17 + app/src/components/AppHeader.vue | 17 + app/src/components/ConfirmModal.vue | 24 + app/src/components/FileTile.vue | 35 + app/src/components/FooterBar.vue | 7 + app/src/components/Icon.vue | 15 + app/src/components/Modal.vue | 50 + app/src/components/MyUploadsModal.vue | 76 + app/src/components/PreviewModal.vue | 84 + app/src/components/QrModal.vue | 18 + app/src/components/ToastHost.vue | 7 + app/src/lib/api.js | 44 + app/src/lib/brand.js | 17 + app/src/lib/filekind.js | 38 + app/src/lib/format.js | 30 + app/src/lib/i18n.js | 20 + app/src/lib/icons.js | 27 + app/src/lib/mine.js | 26 + app/src/lib/pdfThumb.js | 44 + app/src/lib/session.js | 18 + app/src/lib/storage.js | 35 + app/src/lib/thumb.js | 133 + app/src/lib/toast.js | 28 + app/src/main.js | 18 + app/src/pages/AdminPage.vue | 357 ++ app/src/pages/DownloadPage.vue | 212 + app/src/pages/UploadPage.vue | 382 ++ app/src/stores/upload.js | 328 ++ app/src/styles.css | 275 ++ app/vite.config.js | 47 + cli.js | 2 + compose.example.yaml | 29 + config.dev.js | 25 + config.js | 152 + docs/DESIGN.md | 281 ++ docs/brand/ajin-logo.png | Bin 0 -> 12523 bytes docs/configuration.md | 149 + docs/deployment-docker.md | 66 + docs/deployment-systemd.md | 36 + docs/dodo-transfer.service | 22 + docs/layout-customization.md | 36 + docs/mockup/index.html | 893 ++++ docs/mockup/logo.png | Bin 0 -> 12523 bytes docs/nginx-ssl-example.conf | 51 + lang/en.js | 171 + lang/ko.js | 171 + lib/admin.js | 166 + lib/archive.js | 80 + lib/auth.js | 93 + lib/db.js | 216 + lib/endpoints.js | 639 +++ lib/eventBus.js | 8 + lib/mime.js | 48 + lib/passwordHash.js | 48 + lib/settings.js | 167 + lib/store.js | 155 + lib/tusboy/constants.js | 35 + lib/tusboy/errors.js | 51 + lib/tusboy/handlers/error.js | 16 + lib/tusboy/handlers/head.js | 51 + lib/tusboy/handlers/options.js | 23 + lib/tusboy/handlers/patch.js | 87 + lib/tusboy/handlers/post.js | 105 + lib/tusboy/index.js | 90 + lib/tusboy/store/errors.js | 20 + lib/tusboy/tus-header-parser.js | 111 + lib/tusboy/tus-metadata.js | 24 + lib/utils.js | 96 + package-lock.json | 4197 +++++++++++++++++++ package.json | 50 + plugins/file-downloaded-webhook.js | 23 + plugins/file-uploaded-webhook.js | 25 + public/assets/logo.svg | 22 + public/pug/error.pug | 30 + public/pug/page.pug | 17 + public/pug/partials/head_custom.pug | 0 public/robots.txt | 2 + scripts/create-bundle.sh | 48 + scripts/traffic-limit.sh | 43 + tests/integration/features.test.js | 483 +++ tests/integration/middleware.test.js | 170 + tests/integration/one-time-download.test.js | 195 + tests/unit/auth.test.js | 77 + tests/unit/db-remove.test.js | 66 + tests/unit/settings.test.js | 103 + tests/unit/store-containment.test.js | 70 + tests/unit/upload-id.test.js | 41 + 98 files changed, 14424 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.js create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/src/App.vue create mode 100644 app/src/components/AppHeader.vue create mode 100644 app/src/components/ConfirmModal.vue create mode 100644 app/src/components/FileTile.vue create mode 100644 app/src/components/FooterBar.vue create mode 100644 app/src/components/Icon.vue create mode 100644 app/src/components/Modal.vue create mode 100644 app/src/components/MyUploadsModal.vue create mode 100644 app/src/components/PreviewModal.vue create mode 100644 app/src/components/QrModal.vue create mode 100644 app/src/components/ToastHost.vue create mode 100644 app/src/lib/api.js create mode 100644 app/src/lib/brand.js create mode 100644 app/src/lib/filekind.js create mode 100644 app/src/lib/format.js create mode 100644 app/src/lib/i18n.js create mode 100644 app/src/lib/icons.js create mode 100644 app/src/lib/mine.js create mode 100644 app/src/lib/pdfThumb.js create mode 100644 app/src/lib/session.js create mode 100644 app/src/lib/storage.js create mode 100644 app/src/lib/thumb.js create mode 100644 app/src/lib/toast.js create mode 100644 app/src/main.js create mode 100644 app/src/pages/AdminPage.vue create mode 100644 app/src/pages/DownloadPage.vue create mode 100644 app/src/pages/UploadPage.vue create mode 100644 app/src/stores/upload.js create mode 100644 app/src/styles.css create mode 100644 app/vite.config.js create mode 100644 cli.js create mode 100644 compose.example.yaml create mode 100644 config.dev.js create mode 100644 config.js create mode 100644 docs/DESIGN.md create mode 100644 docs/brand/ajin-logo.png create mode 100644 docs/configuration.md create mode 100644 docs/deployment-docker.md create mode 100644 docs/deployment-systemd.md create mode 100644 docs/dodo-transfer.service create mode 100644 docs/layout-customization.md create mode 100644 docs/mockup/index.html create mode 100644 docs/mockup/logo.png create mode 100644 docs/nginx-ssl-example.conf create mode 100644 lang/en.js create mode 100644 lang/ko.js create mode 100644 lib/admin.js create mode 100644 lib/archive.js create mode 100644 lib/auth.js create mode 100644 lib/db.js create mode 100644 lib/endpoints.js create mode 100644 lib/eventBus.js create mode 100644 lib/mime.js create mode 100644 lib/passwordHash.js create mode 100644 lib/settings.js create mode 100644 lib/store.js create mode 100644 lib/tusboy/constants.js create mode 100644 lib/tusboy/errors.js create mode 100644 lib/tusboy/handlers/error.js create mode 100644 lib/tusboy/handlers/head.js create mode 100644 lib/tusboy/handlers/options.js create mode 100644 lib/tusboy/handlers/patch.js create mode 100644 lib/tusboy/handlers/post.js create mode 100644 lib/tusboy/index.js create mode 100644 lib/tusboy/store/errors.js create mode 100644 lib/tusboy/tus-header-parser.js create mode 100644 lib/tusboy/tus-metadata.js create mode 100644 lib/utils.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plugins/file-downloaded-webhook.js create mode 100644 plugins/file-uploaded-webhook.js create mode 100644 public/assets/logo.svg create mode 100644 public/pug/error.pug create mode 100644 public/pug/page.pug create mode 100644 public/pug/partials/head_custom.pug create mode 100644 public/robots.txt create mode 100644 scripts/create-bundle.sh create mode 100644 scripts/traffic-limit.sh create mode 100644 tests/integration/features.test.js create mode 100644 tests/integration/middleware.test.js create mode 100644 tests/integration/one-time-download.test.js create mode 100644 tests/unit/auth.test.js create mode 100644 tests/unit/db-remove.test.js create mode 100644 tests/unit/settings.test.js create mode 100644 tests/unit/store-containment.test.js create mode 100644 tests/unit/upload-id.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b298e07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.idea +.git +data +.env +*.log +screenshots +tmp +temp +node_modules +app/node_modules +npm-debug.log +scripts +docs +tests +_releases +public/app diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3bcca4f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{md,pug}] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7635f4c --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to ".env" (it is ignored by git) and set your own values. +# Passwords are read from here so they do not end up in the compose file. + +# Password of the admin page (/admin). Without it there is no admin page. +PSITRANSFER_ADMIN_PASS=change-me-admin + +# Password to upload. Without it everybody who can reach the page may upload. +PSITRANSFER_UPLOAD_PASS=change-me-upload + +# Optional: key to sign the login tokens. By default it is derived from the two +# passwords, so changing one of them logs everybody out. Set it to keep the logins +# when a password changes. +# PSITRANSFER_TOKEN_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe7f33f --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.idea +.history +.env +*.log +screenshots +data +temp +tmp +node_modules +npm-debug.log +_releases + +# built by "npm run build" (the Docker image builds it) +public/app diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..4aa0e0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v26 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ba3400 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Frontend assets are platform independent: build them once on the native +# build platform instead of under QEMU for every target architecture +FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend + +WORKDIR /build/app + +COPY app/package.json app/package-lock.json ./ +RUN npm ci + +COPY app ./ +# writes to /build/public/app (see app/vite.config.js) +RUN npm run build + + +FROM node:26-alpine + +ENV PSITRANSFER_UPLOAD_DIR=/data \ + NODE_ENV=production + +LABEL org.opencontainers.image.title="Dodo Transfer" \ + org.opencontainers.image.description="Self-hosted file transfer" \ + org.opencontainers.image.source="https://git.dodo-lab.kr/blue2rai/DodoTransfer" \ + org.opencontainers.image.licenses="BSD-2-Clause" + +RUN apk add --no-cache tzdata + +WORKDIR /app + +# Backend deps include native prebuilds (argon2), install for the target platform +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && \ + npm cache clean --force && \ + mkdir /data && \ + chown node /data + +COPY *.js README.md LICENSE ./ +COPY lib lib +COPY lang lang +COPY plugins plugins +COPY public public +COPY --from=frontend /build/public/app public/app + +EXPOSE 3000 +VOLUME ["/data"] + +USER node + +# /healthz needs no login. Port and base url follow the environment settings. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD sh -c 'b="${PSITRANSFER_BASE_URL:-/}"; case "$b" in */) ;; *) b="$b/";; esac; wget -q -O /dev/null "http://127.0.0.1:${PSITRANSFER_PORT:-3000}${b}healthz"' + +CMD ["node", "app.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a62afad --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Copyright 2017 Christoph Wiechert + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fc8f2d --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Dodo Transfer + +사내에서 쓰는 셀프 호스팅 파일 전달 서비스입니다. 파일을 올리면 링크가 만들어지고, 받는 사람은 링크로 내려받습니다. +계정 없이 쓰고, 비밀번호와 보관 기간으로 접근을 관리합니다. + +[PsiTransfer](https://github.com/psi-4ward/psitransfer)(BSD-2-Clause)를 바탕으로 화면과 동작을 새로 만들었습니다. +[출처와 라이선스](#출처와-라이선스)를 보세요. + +## 기능 + +**올리는 사람** + +* 파일을 화면 어디에든 끌어다 놓거나, 붙여넣기(Ctrl+V)로 추가하고, 올리기 전에 썸네일과 미리보기로 확인 +* 큰 파일도 조각으로 나누어 올리고, 연결이 끊기면 이어서 올림 ([tus](https://tus.io)) +* 보관 기간: 1회 다운로드 / 1일 / 3일 / 5일 / 7일 버튼, 그 외는 목록에서 선택 +* 비밀번호는 필수 (1회 다운로드만 예외). 무작위 비밀번호 생성 버튼 +* **"인증된 사용자는 비밀번호 없이 다운로드"**: 업로드 로그인 상태인 사내 사용자는 링크를 열 때 비밀번호를 묻지 않고, + 외부 수신자는 비밀번호를 입력 +* 완료 후 "링크와 비밀번호 복사", QR 코드, 이메일 버튼 +* "내 업로드": 이 브라우저에서 올린 링크를 다시 보기 (비밀번호는 저장하지 않음) +* 업로드 로그인은 한 번 하면 7일 유지되고, 사용할 때마다 연장 + +**받는 사람** + +* 만료일과 남은 시간 표시, 파일별 다운로드와 전체 받기(zip, tar.gz) +* 브라우저가 표시할 수 있는 파일(이미지, PDF, 동영상, 오디오, 텍스트·코드)은 목록의 썸네일과 모달 미리보기로 확인 + (새 창을 열지 않음). 미리보기는 다운로드로 집계되지 않음 +* 1회 다운로드: 한 번 받으면 삭제됨. 미리보기와 썸네일은 제공하지 않음 + +**관리자** (`/admin`) + +* 올라온 파일 조회, 검색, 정렬, 미리보기, 다운로드, 삭제 (파일 단위, bucket 단위) +* 서비스 이름, 로고, 하단 저작권 표기 변경 (저장하면 모든 화면에 바로 적용) + +그 밖에: 라이트/다크 모드(브라우저 설정을 따름), 모바일 화면, 다국어(기본 한국어와 영어, 브라우저 언어를 따름), +웹훅, 도커 상태 확인(`/healthz`). + +## 빠르게 시작하기 + +도커를 권장합니다. 자세한 내용은 [docs/deployment-docker.md](docs/deployment-docker.md)를 보세요. + +```bash +git clone https://git.dodo-lab.kr/blue2rai/DodoTransfer.git +cd DodoTransfer +cp .env.example .env # 관리자와 업로드 비밀번호를 정합니다 +docker compose -f compose.example.yaml up -d --build +``` + +`http://localhost:3377/sharing/`에 접속합니다 (예시 compose는 `/sharing` 하위 경로로 서비스합니다). + +데이터는 컨테이너의 `/data` 볼륨에 저장됩니다. 업로드한 파일과 관리자 설정(이름, 로고)이 모두 여기에 있으므로, +이 볼륨만 유지하면 컨테이너를 다시 만들어도 모두 그대로입니다. + +## 설정 + +모든 설정은 `PSITRANSFER_` 접두사의 환경변수로 바꿀 수 있습니다. 자주 쓰는 것은 다음과 같습니다. +전체 목록은 [docs/configuration.md](docs/configuration.md)에 있습니다. + +| 환경변수 | 설명 | +|---|---| +| `PSITRANSFER_UPLOAD_PASS` | 업로드 비밀번호. 없으면 누구나 올릴 수 있음 | +| `PSITRANSFER_ADMIN_PASS` | 관리자 비밀번호. 없으면 `/admin`이 없음 | +| `PSITRANSFER_BASE_URL` | 하위 경로로 서비스할 때 (`/sharing`) | +| `PSITRANSFER_RETENTIONS` | 보관 기간 목록 (JSON) | +| `PSITRANSFER_QUICK_RETENTIONS` | 버튼으로 보일 보관 기간 (쉼표로 구분) | +| `PSITRANSFER_DEFAULT_RETENTION` | 처음 선택돼 있는 보관 기간 (초) | +| `PSITRANSFER_REQUIRE_BUCKET_PASSWORD` | 비밀번호 필수 여부 (기본 `true`, 1회 다운로드는 예외) | +| `PSITRANSFER_MAX_AGE` | 어떤 파일도 이 시간(초)이 지나면 삭제 | +| `PSITRANSFER_MAX_FILE_SIZE`, `PSITRANSFER_MAX_BUCKET_SIZE` | 크기 제한 (바이트) | +| `PSITRANSFER_TRUST_PROXY` | 리버스 프록시 뒤에서 실제 IP를 로그에 남김 | +| `PSITRANSFER_APP_NAME` | 서비스 이름의 기본값 (관리자 설정이 우선) | + +## 이름과 로고 + +기본은 `Dodo Transfer`와 임의로 만든 도도새 아이콘입니다. 관리자 페이지(`/admin` > 설정)에서 이름, 로고, 하단 +표기를 바꿀 수 있고, 코드를 고치거나 다시 빌드할 필요가 없습니다. 아진산업 로고 파일은 +[docs/brand/ajin-logo.png](docs/brand/ajin-logo.png)에 있습니다. 자세한 내용은 +[docs/layout-customization.md](docs/layout-customization.md)를 보세요. + +## 로그인과 보안 + +* **업로드 로그인:** 업로드 비밀번호로 로그인하면 서버가 서명된 토큰을 발급해 브라우저에 보관합니다. 비밀번호는 저장하지 + 않고, 서버에도 로그인 상태를 저장하지 않으므로 컨테이너를 다시 시작해도 유지됩니다. 업로드나 관리자 비밀번호를 바꾸면 + 모든 토큰이 무효가 됩니다. +* **bucket 비밀번호:** 서버에는 argon2 해시로만 저장됩니다. +* **파일 주소:** `files/++<무작위 UUID>` 형태이고, 목록을 받을 때(비밀번호 확인 후)만 알 수 있습니다. +* **미리보기:** 서버가 파일 이름의 확장자로 형식을 정하고 허용 목록에 있는 것만 인라인으로 내려줍니다 (HTML은 제외). + 스크립트가 실행되지 않도록 응답에 `sandbox` CSP와 `nosniff`를 붙입니다. +* **관리자 다운로드:** 비밀번호 헤더를 보낼 수 없는 일반 링크 대신 60초 동안만 유효한 서명 링크를 씁니다. +* **종단 간 암호화는 없습니다.** 전송 구간 암호화는 nginx의 HTTPS로 처리하세요 ([예시](docs/nginx-ssl-example.conf)). + +## 개발 + +Node.js 24 이상이 필요합니다. + +```bash +npm ci # 서버 의존성 +npm --prefix app ci # 프론트엔드 의존성 +npm run build # app/src를 public/app으로 빌드 +NODE_ENV=dev npm run dev # 개발 서버 (config.dev.js 사용, 관리자 비밀번호 admin) + +npm test # 단위 테스트와 통합 테스트 +``` + +| 위치 | 내용 | +|---|---| +| `app.js`, `config.js` | 시작과 설정 | +| `lib/endpoints.js` | 화면 껍데기, 다운로드, 업로드 라우트 | +| `lib/admin.js` | 관리자 API (파일 관리, 설정) | +| `lib/auth.js`, `lib/settings.js`, `lib/mime.js` | 로그인 토큰, 이름과 로고 설정, 미리보기 형식 | +| `lib/db.js`, `lib/store.js`, `lib/tusboy/` | 파일 인덱스와 만료, 저장소, tus 업로드 | +| `app/` | 프론트엔드 (Vue 3, Vite, Pinia) | +| `lang/` | 화면 문구 (`en.js`가 기본, `ko.js`가 한국어) | +| `plugins/` | 웹훅 | +| `docs/` | 문서, 설계 기록(`DESIGN.md`), 디자인 목업(`mockup/`) | + +디버그 로그는 `DEBUG=psitransfer:* npm start`로 볼 수 있습니다. + +## 참고 + +* 전체 받기(zip, tar.gz)는 이어받기를 지원하지 않습니다. 파일 하나씩 받으면 이어받기가 됩니다. +* 동영상과 오디오는 코덱에 따라 브라우저에서 재생되지 않을 수 있습니다 (예: H.265). 그때는 다운로드를 안내합니다. +* 예전 PsiTransfer가 저장한 `/data`는 그대로 쓸 수 있습니다. 이전 파일에는 썸네일이 없어 아이콘으로 표시됩니다. + +## 출처와 라이선스 + +이 프로젝트는 Christoph Wiechert의 [PsiTransfer](https://github.com/psi-4ward/psitransfer)를 바탕으로 합니다. +원본의 라이선스(BSD 2-Clause)와 저작권 고지는 [LICENSE](LICENSE)에 그대로 유지합니다. diff --git a/app.js b/app.js new file mode 100644 index 0000000..394ee59 --- /dev/null +++ b/app.js @@ -0,0 +1,63 @@ +const https = require('https'); +const fs = require('fs'); +const config = require('./config'); +const app = require('./lib/endpoints'); +const eventBus = require('./lib/eventBus'); +const settings = require('./lib/settings'); + +const appName = () => settings.brand().name; + +/** + * Naming: + * sid: Group of files + * key: File + * fid: {sid}++{key} + */ + +let server; +if(config.port) { + // HTTP Server + server = app.listen(config.port, config.iface, () => { + console.log(`${appName()} listening on http://${config.iface}:${config.port}`); + eventBus.emit('listen', server); + }); +} + +let httpsServer; +if(config.sslPort && config.sslKeyFile && config.sslCertFile) { + // HTTPS Server + const sslOpts = { + key: fs.readFileSync(config.sslKeyFile), + cert: fs.readFileSync(config.sslCertFile) + }; + httpsServer = https.createServer(sslOpts, app) + .listen(config.sslPort, config.iface, () => { + console.log(`${appName()} listening on https://${config.iface}:${config.sslPort}`); + eventBus.emit('listen', httpsServer); + }); +} + + +// graceful shutdown +function shutdown() { + console.log(`${appName()} shutting down...`); + eventBus.emit('shutdown', server || httpsServer); + if(server) { + server.close(() => { + server = false; + if(!server && !httpsServer) process.exit(0); + }); + } + if(httpsServer) { + httpsServer.close(() => { + httpsServer = false; + if(!server && !httpsServer) process.exit(0); + }); + } + setTimeout(function() { + console.log('Could not close connections in time, forcefully shutting down'); + process.exit(1); + }, 15 * 1000); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..df6e022 --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,1603 @@ +{ + "name": "ajin-transfer-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ajin-transfer-app", + "version": "1.0.0", + "dependencies": { + "@nuintun/qrcode": "^5.0.3", + "pdfjs-dist": "^6.5.438", + "pinia": "^4.1.0", + "tus-js-client": "^4.3.1", + "vue": "^3.5.44" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.10", + "vite": "^8.3.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz", + "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.1.0", + "@napi-rs/canvas-darwin-arm64": "1.1.0", + "@napi-rs/canvas-darwin-x64": "1.1.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.1.0", + "@napi-rs/canvas-linux-arm64-musl": "1.1.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-musl": "1.1.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.1.0", + "@napi-rs/canvas-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz", + "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz", + "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz", + "integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nuintun/qrcode": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-5.0.3.tgz", + "integrity": "sha512-eKxU05NO2A5ycOiAKu9XKaf5akfR0wJHgjBvy4Wgy2dOgBjMh58IHz8gvMwS2IBiqWq63ITntuzP/iuVA2wOHg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/combine-errors": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.4.tgz", + "integrity": "sha512-o2AO3DWIBbDrJ16unhIe8+9RwqHKh/DbaIMKrNlXjEc1Od0psMLv+Cn3xocQlipmyZ92Eth4TjJFJQWN3VDFBQ==", + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", + "license": "ISC" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT", + "peer": true + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-base64": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.6.tgz", + "integrity": "sha512-LjkzbLtU6Lx6fjXMYGEACEB7rdtqZtxkBe9aS1ZpDNUyWgUivw3yBEIhQ37OVygJYRt+C+9e9fVT8PpFzH2Hww==", + "license": "BSD-3-Clause" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "license": "MIT", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==", + "license": "MIT" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "license": "MIT", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==", + "license": "MIT" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==", + "license": "MIT" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "license": "MIT", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "license": "MIT", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nostics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.3.0.tgz", + "integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==", + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "6.5.438", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.5.438.tgz", + "integrity": "sha512-vg8Pw1x+AFKy4AMDvhuWl5cOYV8d698UoEANoFFhc79hqXDnZQqnOXoxw3HG907WHOg1JYOvLhP756boddFxpA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.13" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.1.0.tgz", + "integrity": "sha512-+5/HMuVjYQfKlCD5xQ7zfXn1xUzYFEa7pd0DQK5SaMfZCaFRTHlaH/c02N1FoGOdlzSiwf5EOPgJx//1QDw6gw==", + "license": "MIT", + "dependencies": { + "nostics": "^1.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.2.1", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, + "node_modules/tus-js-client": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..aa04990 --- /dev/null +++ b/app/package.json @@ -0,0 +1 @@ +{"name":"ajin-transfer-app","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite build --watch --mode development","build":"vite build"},"dependencies":{"@nuintun/qrcode":"^5.0.3","pdfjs-dist":"^6.5.438","pinia":"^4.1.0","tus-js-client":"^4.3.1","vue":"^3.5.44"},"devDependencies":{"@vitejs/plugin-vue":"^6.0.10","vite":"^8.3.5"}} \ No newline at end of file diff --git a/app/src/App.vue b/app/src/App.vue new file mode 100644 index 0000000..d349353 --- /dev/null +++ b/app/src/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/src/components/AppHeader.vue b/app/src/components/AppHeader.vue new file mode 100644 index 0000000..99fb3b0 --- /dev/null +++ b/app/src/components/AppHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/src/components/ConfirmModal.vue b/app/src/components/ConfirmModal.vue new file mode 100644 index 0000000..31e3a52 --- /dev/null +++ b/app/src/components/ConfirmModal.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/src/components/FileTile.vue b/app/src/components/FileTile.vue new file mode 100644 index 0000000..2297682 --- /dev/null +++ b/app/src/components/FileTile.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/src/components/FooterBar.vue b/app/src/components/FooterBar.vue new file mode 100644 index 0000000..9183243 --- /dev/null +++ b/app/src/components/FooterBar.vue @@ -0,0 +1,7 @@ + + + diff --git a/app/src/components/Icon.vue b/app/src/components/Icon.vue new file mode 100644 index 0000000..34ddfb7 --- /dev/null +++ b/app/src/components/Icon.vue @@ -0,0 +1,15 @@ + + + diff --git a/app/src/components/Modal.vue b/app/src/components/Modal.vue new file mode 100644 index 0000000..4ddf1c0 --- /dev/null +++ b/app/src/components/Modal.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/src/components/MyUploadsModal.vue b/app/src/components/MyUploadsModal.vue new file mode 100644 index 0000000..132c23e --- /dev/null +++ b/app/src/components/MyUploadsModal.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/src/components/PreviewModal.vue b/app/src/components/PreviewModal.vue new file mode 100644 index 0000000..5159511 --- /dev/null +++ b/app/src/components/PreviewModal.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/src/components/QrModal.vue b/app/src/components/QrModal.vue new file mode 100644 index 0000000..d1384cd --- /dev/null +++ b/app/src/components/QrModal.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/src/components/ToastHost.vue b/app/src/components/ToastHost.vue new file mode 100644 index 0000000..0b13a0e --- /dev/null +++ b/app/src/components/ToastHost.vue @@ -0,0 +1,7 @@ + + + diff --git a/app/src/lib/api.js b/app/src/lib/api.js new file mode 100644 index 0000000..e980378 --- /dev/null +++ b/app/src/lib/api.js @@ -0,0 +1,44 @@ +import { getToken } from './session.js'; + +export class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +export function authHeaders(extra = {}) { + const token = getToken(); + return token ? { ...extra, Authorization: `Bearer ${ token }` } : extra; +} + +async function failure(res) { + let message = res.statusText || String(res.status); + try { + const text = await res.text(); + try { + message = JSON.parse(text).message || text; + } catch (e) { + message = text || message; + } + } catch (e) { /* keep status text */ } + return new HttpError(res.status, message); +} + +/** Relative urls are resolved against the of the page. */ +export async function getJSON(url, { method = 'GET', headers = {}, body, auth = true } = {}) { + const res = await fetch(url, { method, headers: auth ? authHeaders(headers) : headers, body }); + if (!res.ok) throw await failure(res); + return res.status === 204 ? null : res.json(); +} + +export function sendJSON(method, url, data, options = {}) { + return getJSON(url, { + ...options, + method, + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + body: JSON.stringify(data), + }); +} + +export const uploadPath = () => (window.PSITRANSFER && window.PSITRANSFER.uploadPath) || '/'; diff --git a/app/src/lib/brand.js b/app/src/lib/brand.js new file mode 100644 index 0000000..5d98ff0 --- /dev/null +++ b/app/src/lib/brand.js @@ -0,0 +1,17 @@ +import { reactive } from 'vue'; + +// Start with what the server rendered into the page, the admin settings page +// updates it when something is saved. +export const brand = reactive({ + name: 'Transfer', + logoUrl: '', + footer: '', + ...((window.PSITRANSFER && window.PSITRANSFER.brand) || {}), +}); + +export function applyBrand(next) { + Object.assign(brand, next); + document.title = brand.name; + const icon = document.querySelector('link[rel="icon"]'); + if (icon) icon.href = brand.logoUrl; +} diff --git a/app/src/lib/filekind.js b/app/src/lib/filekind.js new file mode 100644 index 0000000..f53a5da --- /dev/null +++ b/app/src/lib/filekind.js @@ -0,0 +1,38 @@ +// What the browser can show inline. Keep in sync with lib/mime.js of the server, +// which decides what is really served inline. +const IMAGE = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg']; +const VIDEO = ['mp4', 'm4v', 'mov', 'webm', 'ogv']; +const AUDIO = ['mp3', 'wav', 'ogg', 'm4a', 'flac']; +const TEXT = ['txt', 'md', 'log', 'json', 'xml', 'csv', 'tsv', 'yml', 'yaml', 'ini', 'conf', 'cfg', + 'js', 'mjs', 'ts', 'jsx', 'tsx', 'vue', 'css', 'scss', 'less', 'sh', 'bat', 'cmd', 'ps1', 'py', + 'java', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'sql', 'lua', 'pug', 'go', 'rs', 'rb']; + +export const extOf = name => { + const m = /\.([^.\\/]+)$/.exec(String(name || '')); + return m ? m[1].toLowerCase() : ''; +}; + +/** 'image' | 'pdf' | 'video' | 'audio' | 'text' | null */ +export function previewKindOf(name) { + const ext = extOf(name); + if (IMAGE.includes(ext)) return 'image'; + if (ext === 'pdf') return 'pdf'; + if (VIDEO.includes(ext)) return 'video'; + if (AUDIO.includes(ext)) return 'audio'; + if (TEXT.includes(ext)) return 'text'; + return null; +} + +/** Label and colour class of the tile that stands for a file. */ +export function tileOf(name) { + const ext = extOf(name); + const label = (ext && ext.length <= 4 ? ext : 'file').toUpperCase(); + let cls = 't-txt'; + if (IMAGE.includes(ext)) cls = 't-img'; + else if (ext === 'pdf') cls = 't-pdf'; + else if (['xls', 'xlsx', 'csv'].includes(ext)) cls = 't-xls'; + else if (['doc', 'docx', 'ppt', 'pptx', 'hwp', 'hwpx'].includes(ext)) cls = 't-doc'; + else if (['zip', '7z', 'tar', 'gz', 'rar'].includes(ext)) cls = 't-zip'; + else if (VIDEO.includes(ext) || AUDIO.includes(ext)) cls = 't-doc'; + return { ext: label, cls }; +} diff --git a/app/src/lib/format.js b/app/src/lib/format.js new file mode 100644 index 0000000..4d59979 --- /dev/null +++ b/app/src/lib/format.js @@ -0,0 +1,30 @@ +import { t } from './i18n.js'; + +export function fmtSize(bytes) { + if (!Number.isFinite(bytes)) return ''; + if (bytes < 1024) return `${ bytes } B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let i = -1; + do { + bytes /= 1024; + i++; + } while (bytes >= 1024 && i < units.length - 1); + return `${ bytes >= 100 ? bytes.toFixed(0) : bytes.toFixed(1) } ${ units[i] }`; +} + +const pad = n => String(n).padStart(2, '0'); + +/** 2026-11-12 18:30 in the time zone of the browser */ +export function fmtDate(ms) { + const d = new Date(ms); + return `${ d.getFullYear() }-${ pad(d.getMonth() + 1) }-${ pad(d.getDate()) } ${ pad(d.getHours()) }:${ pad(d.getMinutes()) }`; +} + +export function remain(ms, now = Date.now()) { + const hours = (ms - now) / 3600e3; + if (hours < 1) return t('remainMinutes', Math.max(1, Math.round(hours * 60))); + if (hours < 48) return t('remainHours', Math.round(hours)); + return t('remainDays', Math.round(hours / 24)); +} + +export const isSoon = (ms, now = Date.now()) => ms - now < 24 * 3600e3; diff --git a/app/src/lib/i18n.js b/app/src/lib/i18n.js new file mode 100644 index 0000000..608ad2b --- /dev/null +++ b/app/src/lib/i18n.js @@ -0,0 +1,20 @@ +// Translations come from the server (lang.json), the browser language decides. +// Placeholders {0}, {1} are replaced by the arguments. +let dict = {}; + +export function setLang(next) { + dict = next || {}; +} + +export function t(key, ...args) { + let text = dict[key]; + if (text === undefined) return key; + args.forEach((arg, i) => { + text = text.split(`{${ i }}`).join(String(arg)); + }); + return text; +} + +export function retentionLabel(key, fallback) { + return (dict.retentions && dict.retentions[key]) || fallback || key; +} diff --git a/app/src/lib/icons.js b/app/src/lib/icons.js new file mode 100644 index 0000000..ce63970 --- /dev/null +++ b/app/src/lib/icons.js @@ -0,0 +1,27 @@ +// Stroke icons (24x24 grid) +export const ICONS = { + upload: '', + download: '', + plus: '', + x: '', + check: '', + copy: '', + eye: '', + link: '', + lock: '', + trash: '', + search: '', + mail: '', + qr: '', + history: '', + refresh: '', + chevron: '', + left: '', + right: '', + clock: '', + info: '', + shield: '', + alert: '', + archive: '', + key: '', +}; diff --git a/app/src/lib/mine.js b/app/src/lib/mine.js new file mode 100644 index 0000000..696230b --- /dev/null +++ b/app/src/lib/mine.js @@ -0,0 +1,26 @@ +import { load, save } from './storage.js'; + +// "My uploads": a list kept in this browser only. Passwords are never stored. +const KEY = 'ajin-transfer.mine'; +const MAX_ITEMS = 100; +const KEEP_EXPIRED_MS = 30 * 24 * 3600e3; + +export function readMine() { + const list = load(KEY); + return Array.isArray(list) ? list : []; +} + +function prune(list, now = Date.now()) { + return list + .filter(item => !item.expiresAt || item.expiresAt + KEEP_EXPIRED_MS > now) + .slice(0, MAX_ITEMS); +} + +export function addMine(entry) { + const list = readMine().filter(item => item.sid !== entry.sid); + save(KEY, prune([entry, ...list])); +} + +export function removeMine(sid) { + save(KEY, readMine().filter(item => item.sid !== sid)); +} diff --git a/app/src/lib/pdfThumb.js b/app/src/lib/pdfThumb.js new file mode 100644 index 0000000..a05d842 --- /dev/null +++ b/app/src/lib/pdfThumb.js @@ -0,0 +1,44 @@ +import * as pdfjs from 'pdfjs-dist/build/pdf.min.mjs'; +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; + +const MAX_PDF_BYTES = 50 * 1024 * 1024; + +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + +/** + * First page of a pdf. Encrypted, broken and very large files give no thumbnail + * (null), which only means the file keeps its icon. + */ +export async function pdfThumb(file, size, toBlob, makeCanvas) { + if (file.size > MAX_PDF_BYTES) return null; + const data = new Uint8Array(await file.arrayBuffer()); + // font data and CMaps are copied next to main.js by the build (vite.config.js) + const assets = new URL('app/', document.baseURI).href; + const task = pdfjs.getDocument({ + data, + isEvalSupported: false, + standardFontDataUrl: `${ assets }standard_fonts/`, + cMapUrl: `${ assets }cmaps/`, + cMapPacked: true, + }); + let blob = null; + try { + const doc = await task.promise; + const page = await doc.getPage(1); + const base = page.getViewport({ scale: 1 }); + // fill the width, show the top of the page + const viewport = page.getViewport({ scale: size / base.width }); + const c = makeCanvas(); + const ctx = c.getContext('2d'); + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, size, size); + await page.render({ canvasContext: ctx, canvas: c, viewport }).promise; + blob = await toBlob(c); + } catch (e) { + console.debug('pdf thumbnail failed', e); + } finally { + // releases the worker in the background: the thumbnail must not wait for it + Promise.resolve().then(() => task.destroy()).catch(() => {}); + } + return blob; +} diff --git a/app/src/lib/session.js b/app/src/lib/session.js new file mode 100644 index 0000000..3c40255 --- /dev/null +++ b/app/src/lib/session.js @@ -0,0 +1,18 @@ +import { load, save, remove } from './storage.js'; + +// The login token issued by the server. It is renewed on every use, so it +// lasts as long as the browser keeps being used. +const KEY = 'ajin-transfer.session'; + +export function getToken() { + const s = load(KEY); + return s && s.token && s.exp > Date.now() ? s.token : null; +} + +export function setSession(session) { + if (session && session.token) save(KEY, { token: session.token, exp: session.exp }); +} + +export function clearSession() { + remove(KEY); +} diff --git a/app/src/lib/storage.js b/app/src/lib/storage.js new file mode 100644 index 0000000..43f159f --- /dev/null +++ b/app/src/lib/storage.js @@ -0,0 +1,35 @@ +// localStorage can be missing or throw (private windows, blocked site data). +// Everything keeps working with a copy in memory. +const memory = new Map(); + +export function load(key) { + try { + const raw = localStorage.getItem(key); + if (raw !== null) return JSON.parse(raw); + } catch (e) { /* use memory */ } + return memory.has(key) ? memory.get(key) : null; +} + +export function save(key, value) { + memory.set(key, value); + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { /* memory only */ } +} + +export function remove(key) { + memory.delete(key); + try { + localStorage.removeItem(key); + } catch (e) { /* ignore */ } +} + +export function persistent() { + try { + localStorage.setItem('__probe', '1'); + localStorage.removeItem('__probe'); + return true; + } catch (e) { + return false; + } +} diff --git a/app/src/lib/thumb.js b/app/src/lib/thumb.js new file mode 100644 index 0000000..a81cd5f --- /dev/null +++ b/app/src/lib/thumb.js @@ -0,0 +1,133 @@ +import { previewKindOf } from './filekind.js'; + +// Thumbnails are made by the browser of the uploader and sent to the server +// together with the file, so the server needs no image tools. +// The result is a small image (webp, png or jpeg, far below the 64KB limit) +// or null when no thumbnail can be made. + +const SIZE = 160; +const MAX_IMAGE_BYTES = 60 * 1024 * 1024; + +function canvas() { + const c = document.createElement('canvas'); + c.width = SIZE; + c.height = SIZE; + return c; +} + +function toBlob(c) { + return new Promise(resolve => { + c.toBlob(blob => { + if (blob && blob.size <= 64 * 1024) return resolve(blob); + // too big for a thumbnail (very detailed): try a jpeg + c.toBlob(jpeg => resolve(jpeg && jpeg.size <= 64 * 1024 ? jpeg : null), 'image/jpeg', 0.6); + }, 'image/webp', 0.8); + }); +} + +function withTimeout(promise, ms) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout')), ms); + promise.then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); }); + }); +} + +/** Draw a source into the square, cropping like object-fit: cover. */ +function drawCover(ctx, source, w, h) { + const r = Math.max(SIZE / w, SIZE / h); + const dw = w * r; + const dh = h * r; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, SIZE, SIZE); + ctx.drawImage(source, (SIZE - dw) / 2, (SIZE - dh) / 2, dw, dh); +} + +function loadImageElement(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('image')); + img.src = url; + }); +} + +async function imageThumb(file) { + if (file.size > MAX_IMAGE_BYTES) return null; + const c = canvas(); + const ctx = c.getContext('2d'); + try { + const bitmap = await createImageBitmap(file); + drawCover(ctx, bitmap, bitmap.width, bitmap.height); + bitmap.close(); + } catch (e) { + // svg and some formats only work through an image element + const url = URL.createObjectURL(file); + try { + const img = await loadImageElement(url); + drawCover(ctx, img, img.naturalWidth || SIZE, img.naturalHeight || SIZE); + } finally { + URL.revokeObjectURL(url); + } + } + return toBlob(c); +} + +async function videoThumb(file) { + const url = URL.createObjectURL(file); + try { + const video = document.createElement('video'); + video.muted = true; + video.preload = 'metadata'; + video.playsInline = true; + video.src = url; + await withTimeout(new Promise((resolve, reject) => { + video.onloadeddata = resolve; + video.onerror = () => reject(new Error('video')); + }), 8000); + // a frame a little after the start is usually not black + video.currentTime = Math.min(1, (video.duration || 2) / 10); + await withTimeout(new Promise((resolve, reject) => { + video.onseeked = resolve; + video.onerror = () => reject(new Error('video')); + }), 8000); + const c = canvas(); + drawCover(c.getContext('2d'), video, video.videoWidth || SIZE, video.videoHeight || SIZE); + return toBlob(c); + } finally { + URL.revokeObjectURL(url); + } +} + +async function textThumb(file) { + const text = await file.slice(0, 1200).text(); + const c = canvas(); + const ctx = c.getContext('2d'); + ctx.fillStyle = '#fbfbfd'; + ctx.fillRect(0, 0, SIZE, SIZE); + ctx.fillStyle = '#5b647a'; + ctx.font = '11px ui-monospace, Consolas, monospace'; + text.split(/\r?\n/).slice(0, 11).forEach((line, i) => ctx.fillText(line.slice(0, 24), 8, 16 + i * 13)); + return toBlob(c); +} + +async function pdfThumb(file) { + // pdf.js (about 1MB) is only loaded when a pdf is selected + const { pdfThumb: render } = await import('./pdfThumb.js'); + return render(file, SIZE, toBlob, canvas); +} + +/** @returns {Promise} never rejects */ +export async function makeThumb(file) { + try { + switch (previewKindOf(file.name)) { + case 'image': return await imageThumb(file); + case 'video': return await withTimeout(videoThumb(file), 20000); + case 'text': return await textThumb(file); + case 'pdf': return await withTimeout(pdfThumb(file), 15000); + default: return null; + } + } catch (e) { + console.debug('thumbnail failed', file.name, e); + return null; + } +} diff --git a/app/src/lib/toast.js b/app/src/lib/toast.js new file mode 100644 index 0000000..041109a --- /dev/null +++ b/app/src/lib/toast.js @@ -0,0 +1,28 @@ +import { reactive } from 'vue'; + +export const toastState = reactive({ message: '', visible: false }); +let timer = null; + +export function toast(message, ms = 2000) { + toastState.message = message; + toastState.visible = true; + clearTimeout(timer); + timer = setTimeout(() => { toastState.visible = false; }, ms); +} + +export async function copyText(text, message) { + try { + await navigator.clipboard.writeText(text); + } catch (e) { + // no clipboard api on plain http: fall back to a hidden textarea + const area = document.createElement('textarea'); + area.value = text; + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { document.execCommand('copy'); } catch (err) { /* nothing more to try */ } + area.remove(); + } + toast(message); +} diff --git a/app/src/main.js b/app/src/main.js new file mode 100644 index 0000000..75393e4 --- /dev/null +++ b/app/src/main.js @@ -0,0 +1,18 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import './styles.css'; +import App from './App.vue'; +import { setLang } from './lib/i18n.js'; +import { getJSON } from './lib/api.js'; + +async function start() { + // the translations decide the texts of everything, load them first + try { + setLang(await getJSON('lang.json', { auth: false })); + } catch (e) { + console.error(e); + } + createApp(App).use(createPinia()).mount('#app'); +} + +start(); diff --git a/app/src/pages/AdminPage.vue b/app/src/pages/AdminPage.vue new file mode 100644 index 0000000..6976a4e --- /dev/null +++ b/app/src/pages/AdminPage.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/src/pages/DownloadPage.vue b/app/src/pages/DownloadPage.vue new file mode 100644 index 0000000..82dc3ae --- /dev/null +++ b/app/src/pages/DownloadPage.vue @@ -0,0 +1,212 @@ + + + diff --git a/app/src/pages/UploadPage.vue b/app/src/pages/UploadPage.vue new file mode 100644 index 0000000..f55778b --- /dev/null +++ b/app/src/pages/UploadPage.vue @@ -0,0 +1,382 @@ + + + diff --git a/app/src/stores/upload.js b/app/src/stores/upload.js new file mode 100644 index 0000000..55ed4c5 --- /dev/null +++ b/app/src/stores/upload.js @@ -0,0 +1,328 @@ +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; +import * as tus from 'tus-js-client'; +import { authHeaders, uploadPath } from '../lib/api.js'; +import { clearSession } from '../lib/session.js'; +import { addMine } from '../lib/mine.js'; +import { makeThumb } from '../lib/thumb.js'; +import { previewKindOf, tileOf } from '../lib/filekind.js'; +import { t } from '../lib/i18n.js'; +import { fmtSize } from '../lib/format.js'; + +const CHUNK_SIZE = 5_000_000; +const PARALLEL = 3; +const MAX_RETRIES = 30; + +// bucket id: ?sid=name sets it explicitly, otherwise 12 random hex characters +function newSid() { + const m = /[?&]sid=([^&]+)/.exec(location.search); + if (m) { + const sid = decodeURIComponent(m[1]); + // same rules as the server: a plain name, no path + if (/^[^/\\+\u0000-\u001f]{1,100}$/.test(sid) && sid !== '.' && sid !== '..') return sid; + } + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +// running tus uploads, kept outside of the reactive state +const handles = new Map(); +let nextId = 1; +let onlineHandler = null; + +export const useUpload = defineStore('upload', { + state: () => ({ + sid: newSid(), + config: null, + retention: '', + password: '', + bypass: true, + files: [], + // ready | uploading | failed | done + phase: 'ready', + offline: false, + // the login expired while uploading + authLost: false, + message: '', + result: null, + }), + + getters: { + totalSize: s => s.files.reduce((sum, f) => sum + f.size, 0), + uploadedBytes: s => s.files.reduce((sum, f) => sum + Math.min(f.progress, f.size), 0), + percent() { + return this.totalSize ? Math.min(100, Math.round(this.uploadedBytes / this.totalSize * 100)) : 0; + }, + oneTime: s => s.retention === 'one-time', + passwordRequired() { + return !!this.config && this.config.requireBucketPassword && !this.oneTime; + }, + hasPassword: s => s.password.trim().length > 0, + sizeError() { + const max = this.config && this.config.maxBucketSize; + return max && this.totalSize > max ? t('bucketSizeExceed', fmtSize(this.totalSize), fmtSize(max)) : ''; + }, + canUpload() { + return this.phase === 'ready' + && this.files.length > 0 + && !this.files.some(f => f.error) + && !this.sizeError + && !(this.passwordRequired && !this.hasPassword); + }, + editable: s => s.phase === 'ready', + // password waived for logged in users only makes sense with a password and a login + canBypass() { + return !!this.config && this.config.authRequired; + }, + }, + + actions: { + init(config) { + this.config = config; + if (!this.retention) this.retention = config.defaultRetention; + }, + + addFiles(list) { + if (!this.editable) return 0; + const max = this.config && this.config.maxFileSize; + let added = 0; + for (const file of list) { + // a folder or an unreadable entry shows up as an empty file without type + const item = reactive({ + id: nextId++, + file, + name: file.name, + size: file.size, + comment: '', + ...tileOf(file.name), + previewKind: previewKindOf(file.name), + thumbUrl: '', + thumbBlob: null, + thumbPromise: null, + progress: 0, + status: 'ready', + error: max && file.size > max ? t('fileSizeExceed', fmtSize(file.size), fmtSize(max)) : '', + uploadUrl: '', + retries: 0, + }); + item.thumbPromise = makeThumb(file).then(blob => { + if (!blob) return; + item.thumbBlob = blob; + item.thumbUrl = URL.createObjectURL(blob); + }); + this.files.push(item); + added++; + } + return added; + }, + + removeFile(id) { + if (!this.editable) return; + const i = this.files.findIndex(f => f.id === id); + if (i < 0) return; + const [item] = this.files.splice(i, 1); + if (item.thumbUrl) URL.revokeObjectURL(item.thumbUrl); + }, + + start() { + if (!this.canUpload && this.phase !== 'failed') return; + this.phase = 'uploading'; + this.message = ''; + this.authLost = false; + for (const f of this.files) { + if (f.status === 'error') { + f.status = 'ready'; + f.error = ''; + f.retries = 0; + } + } + this._pump(); + }, + + // start waiting files until PARALLEL uploads are running + _pump() { + if (this.phase !== 'uploading') return; + let running = this.files.filter(f => f.status === 'uploading').length; + for (const f of this.files) { + if (running >= PARALLEL) break; + if (f.status === 'ready' && !f.error) { + this._launch(f); + running++; + } + } + if (running === 0) this._settle(); + }, + + _launch(item) { + item.status = 'uploading'; + const metadata = { + sid: this.sid, + retention: this.retention, + name: item.name, + comment: item.comment || '', + type: item.file.type || '', + }; + if (this.hasPassword) { + metadata.password = this.password; + if (this.bypass && this.canBypass) metadata.internalBypass = 'true'; + } + const upload = new tus.Upload(item.file, { + endpoint: uploadPath() + 'files', + uploadUrl: item.uploadUrl || null, + metadata, + chunkSize: CHUNK_SIZE, + retryDelays: [0, 1000, 3000, 5000], + storeFingerprintForResuming: false, + onBeforeRequest: req => { + const headers = authHeaders(); + if (headers.Authorization) req.setHeader('Authorization', headers.Authorization); + }, + onProgress: bytes => { + item.progress = bytes; + item.retries = 0; + }, + onError: error => this._failed(item, error, upload), + onSuccess: () => this._succeeded(item, upload), + }); + handles.set(item.id, upload); + upload.start(); + }, + + async _succeeded(item, upload) { + item.uploadUrl = upload.url || item.uploadUrl; + item.progress = item.size; + handles.delete(item.id); + // the thumbnail belongs to the finished upload, a failure here is not an upload failure + try { + await item.thumbPromise; + if (item.thumbBlob && item.uploadUrl) { + const fid = decodeURIComponent(item.uploadUrl.split('/').pop()); + await fetch(`${ uploadPath() }files/${ encodeURIComponent(fid) }.thumb`, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': item.thumbBlob.type || 'image/webp' }), + body: item.thumbBlob, + }); + } + } catch (e) { /* the file keeps its icon */ } + item.status = 'done'; + this._pump(); + }, + + _failed(item, error, upload) { + item.uploadUrl = (upload && upload.url) || item.uploadUrl; + handles.delete(item.id); + + // no network: wait until the browser is back online + if (!navigator.onLine) { + item.status = 'ready'; + this.offline = true; + this.message = t('offline'); + if (!onlineHandler) { + onlineHandler = () => { + window.removeEventListener('online', onlineHandler); + onlineHandler = null; + this.offline = false; + this.message = ''; + this._pump(); + }; + window.addEventListener('online', onlineHandler); + } + return; + } + + const res = error && error.originalResponse; + const status = res ? res.getStatus() : 0; + if (status >= 400 && status < 500) { + item.status = 'error'; + item.error = this._messageOf(res, error); + if (status === 401 || status === 403) { + // the login is gone, the page asks for it again and then continues + clearSession(); + this.authLost = true; + } + this._pump(); + return; + } + + // server or network trouble: try again with a growing delay + if (item.retries >= MAX_RETRIES) { + item.status = 'error'; + item.error = this._messageOf(res, error); + this._pump(); + return; + } + item.retries++; + const delay = Math.min(500 * 1.7 ** item.retries, 10000); + setTimeout(() => { + if (this.phase === 'uploading' && item.status === 'uploading') this._launch(item); + }, delay); + }, + + _messageOf(res, error) { + if (res) { + try { + const body = res.getBody(); + try { + return JSON.parse(body).message || body; + } catch (e) { + if (body) return body; + } + } catch (e) { /* use the error message */ } + } + return (error && error.message) || String(error); + }, + + // nothing is running anymore: finished or stuck on errors + async _settle() { + if (this.offline) return; + if (this.files.every(f => f.status === 'done')) { + await this._finish(); + } else if (this.files.some(f => f.status === 'error')) { + this.phase = 'failed'; + this.message = this.authLost ? t('accessDenied') : t('uploadFailed'); + } + }, + + async _finish() { + try { + // no more files for this bucket + await fetch(`${ uploadPath() }files/${ encodeURIComponent(this.sid) }?lock=yes`, { + method: 'PATCH', + headers: authHeaders({ 'Tus-Resumable': '1.0.0' }), + }); + } catch (e) { /* the bucket stays open, the files are uploaded */ } + + const retention = this.retention; + const createdAt = Date.now(); + const expiresAt = retention === 'one-time' ? null : createdAt + Number(retention) * 1000; + const link = new URL(this.sid, document.baseURI).href; + this.result = { + link, + password: this.password.trim(), + bypass: this.hasPassword && this.bypass && this.canBypass, + retention, + expiresAt, + createdAt, + }; + this.phase = 'done'; + this.message = ''; + addMine({ + sid: this.sid, + link, + names: this.files.map(f => f.name), + size: this.totalSize, + createdAt, + expiresAt, + retention, + hasPassword: this.hasPassword, + }); + }, + + /** The login was renewed after it expired during an upload. */ + resumeAfterLogin() { + this.authLost = false; + if (this.phase === 'failed') this.start(); + }, + }, +}); diff --git a/app/src/styles.css b/app/src/styles.css new file mode 100644 index 0000000..891c16b --- /dev/null +++ b/app/src/styles.css @@ -0,0 +1,275 @@ + +:root{ + --font:"Pretendard Variable",Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; + --bg:#f4f5f7;--surface:#fff;--surface-2:#f6f7f9;--border:#e3e6ec; + --text:#141821;--muted:#667085; + --accent:#3552d6;--accent-hover:#2b44b5;--accent-soft:#e9edfd;--accent-ink:#2b44b5; + --ok:#12805c;--ok-soft:#e1f5ec;--warn:#a85a05;--warn-soft:#fff2da;--danger:#c0362c;--danger-soft:#fdeceb; + --shadow:0 1px 2px rgba(16,24,40,.05),0 6px 20px rgba(16,24,40,.06); + --radius:14px; +} +@media (prefers-color-scheme:dark){ + :root:not([data-theme=light]){ + --bg:#0e1117;--surface:#161a22;--surface-2:#1c212b;--border:#2a303c; + --text:#eceff4;--muted:#98a2b3; + --accent:#4d69ee;--accent-hover:#6079f2;--accent-soft:#1d2546;--accent-ink:#9db0ff; + --ok:#4cc79a;--ok-soft:#12302a;--warn:#f0b35a;--warn-soft:#38290f;--danger:#ea6a5f;--danger-soft:#3a1816; + --shadow:0 1px 2px rgba(0,0,0,.4),0 6px 20px rgba(0,0,0,.35); + } +} +:root[data-theme=dark]{ + --bg:#0e1117;--surface:#161a22;--surface-2:#1c212b;--border:#2a303c; + --text:#eceff4;--muted:#98a2b3; + --accent:#4d69ee;--accent-hover:#6079f2;--accent-soft:#1d2546;--accent-ink:#9db0ff; + --ok:#4cc79a;--ok-soft:#12302a;--warn:#f0b35a;--warn-soft:#38290f;--danger:#ea6a5f;--danger-soft:#3a1816; + --shadow:0 1px 2px rgba(0,0,0,.4),0 6px 20px rgba(0,0,0,.35); +} +*{box-sizing:border-box} +html,body{margin:0} +body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased} +button,input,select{font:inherit;color:inherit} +h1,h2,h3,p{margin:0} +.muted{color:var(--muted)} +.ic{flex:none;vertical-align:middle} + + +/* ---- 공통 ---- */ +.hdr{flex:none;background:var(--surface);border-bottom:1px solid var(--border)} +.hdr .in{max-width:940px;margin:0 auto;padding:12px 20px;display:flex;align-items:center;gap:10px} +.brand{display:flex;align-items:center;gap:12px;font-weight:800;font-size:23px;letter-spacing:-.015em} +.brand img{width:38px;height:38px;display:block} +.badge-admin{font-size:11px;font-weight:700;background:var(--accent-soft);color:var(--accent-ink);padding:2px 8px;border-radius:99px} +.spacer{flex:1} +.scroll{flex:1;min-height:0;overflow:auto} +.wrap{max-width:940px;margin:0 auto;padding:20px;width:100%} +.foot{flex:none;text-align:center;color:var(--muted);font-size:12px;padding:10px 16px} + +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--border);background:var(--surface);padding:8px 14px;border-radius:10px;cursor:pointer;font-weight:600;font-size:14px;transition:background .15s,border-color .15s;white-space:nowrap} +.btn:hover{background:var(--surface-2)} +.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} +.btn.primary:hover{background:var(--accent-hover)} +.btn.danger{background:var(--danger);border-color:var(--danger);color:#fff} +.btn.ghost{border-color:transparent;background:transparent} +.btn.ghost:hover{background:var(--surface-2)} +.btn.sm{padding:6px 10px;font-size:13px;border-radius:8px} +.btn.lg{padding:12px 24px;font-size:16px;border-radius:12px} +.btn.ic-only{padding:7px} +.btn:disabled{opacity:.45;cursor:not-allowed} +.btn:focus-visible,.chip:focus-visible,input:focus-visible,select:focus-visible,.drop:focus-visible{outline:2px solid var(--accent);outline-offset:2px} + +.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)} +.tag{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;padding:2px 8px;border-radius:99px;background:var(--accent-soft);color:var(--accent-ink)} +.tag.opt{background:var(--surface-2);color:var(--muted)} +.tag.ok{background:var(--ok-soft);color:var(--ok)} +.tag.warn{background:var(--warn-soft);color:var(--warn)} +.tag.bad{background:var(--danger-soft);color:var(--danger)} + +.tile{--c:var(--muted);width:52px;height:52px;border-radius:12px;display:grid;place-items:center;font-size:11px;font-weight:800;flex:none;color:var(--c);background:color-mix(in srgb,var(--c) 14%,transparent);letter-spacing:.02em} +button.tile{position:relative;border:0;padding:0;font-family:inherit;cursor:pointer;transition:box-shadow .15s,transform .15s} +button.tile:hover{box-shadow:0 0 0 2px var(--accent);transform:translateY(-1px)} +button.tile:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.tile.th{position:relative;padding:0;background:var(--surface-2);border:1px solid var(--border)} +.tile.th img{width:100%;height:100%;object-fit:cover;border-radius:11px;display:block} +.tile.th .ext{position:absolute;left:3px;bottom:3px;background:rgba(15,18,28,.74);color:#fff;font-size:9px;font-weight:800;padding:1px 5px;border-radius:5px;letter-spacing:.02em} +button.tile .eye{position:absolute;right:-6px;bottom:-6px;width:21px;height:21px;border-radius:50%;background:var(--surface);border:1px solid var(--border);color:var(--accent-ink);display:grid;place-items:center;box-shadow:0 1px 3px rgba(16,24,40,.15)} +.t-img{--c:#3b82f6}.t-pdf{--c:#ef4444}.t-xls{--c:#16a34a}.t-doc{--c:#6366f1}.t-zip{--c:#b7791f}.t-txt{--c:#64748b} + +.bar{height:6px;border-radius:99px;background:var(--surface-2);overflow:hidden} +.bar>i{display:block;height:100%;width:0;background:var(--accent);border-radius:99px;transition:width .15s linear} +.bar.ok>i{background:var(--ok)} + +input[type=text],input[type=password],input[type=search]{width:100%;border:1px solid var(--border);background:var(--surface);border-radius:10px;padding:9px 12px;font-size:14px} +input::placeholder{color:var(--muted)} + +/* ---- 업로드 ---- */ +.upload-wrap{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding-bottom:6px} +.ctl-card{flex:none;padding:12px 14px} +.ctl-top{display:flex;align-items:center;gap:12px} +.ctl-top .sum{flex:1;min-width:0;color:var(--muted);font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ctl-top .warn{color:var(--warn);font-size:13px;font-weight:600;white-space:nowrap} +.btn.up{padding:9px 22px;font-size:15px} +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.fl{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.ctl-toggle{display:none;align-items:center;justify-content:space-between;width:100%;margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);cursor:pointer;font-weight:600;font-size:14px} +.ctl{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:12px 24px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);align-items:start} +.lbl{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:700;color:var(--muted);margin-bottom:8px;white-space:nowrap} +.fl .lbl{margin:0} +.chips{display:flex;flex-wrap:wrap;gap:8px} +.chip{border:1px solid var(--border);background:var(--surface);border-radius:9px;padding:6px 12px;cursor:pointer;font-weight:600;font-size:13.5px;transition:background .15s} +.chip:hover{background:var(--surface-2)} +.chip[aria-checked=true]{background:var(--accent);border-color:var(--accent);color:#fff} +.sel{position:relative;display:inline-block} +.sel select{appearance:none;-webkit-appearance:none;border:1px dashed var(--border);background:var(--surface);border-radius:9px;padding:6px 30px 6px 12px;cursor:pointer;font-weight:600;font-size:13.5px} +.sel select.active{background:var(--accent);border:1px solid var(--accent);color:#fff} +.sel select.active option{color:var(--text);background:var(--surface)} +.sel::after{content:"";position:absolute;right:13px;top:50%;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none;color:var(--muted)} +.sel:has(select.active)::after{color:#fff} +.pw{display:flex;gap:8px} +.pwi{position:relative;flex:1;min-width:0} +.pwi input{padding-right:34px} +.clr{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:26px;height:26px;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center} +.clr:hover{background:var(--surface-2);color:var(--text)} +.clr:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.chk{display:flex;align-items:flex-start;gap:9px;margin-top:8px;font-size:13px;cursor:pointer} +.chk input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent)} +.chk.off{opacity:.5;cursor:not-allowed} +.ctl-foot{display:flex;align-items:center;gap:14px;margin-top:18px;padding-top:16px;border-top:1px solid var(--border)} +.ctl-foot .sum{color:var(--muted);font-size:14px;flex:1} +.ctl-foot .warn{color:var(--warn);font-size:13px;font-weight:600} + +.list-card{flex:1;min-height:220px;display:flex;flex-direction:column} +.upload-wrap{overflow:auto} +.list-h{flex:none;display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid var(--border);font-weight:700} +.list-h span{color:var(--muted);font-weight:500;font-size:14px} +.list-b{flex:1;min-height:0;overflow:auto;padding:6px 8px} +.row{display:flex;align-items:center;gap:14px;padding:12px 10px} +.row+.row{border-top:1px solid var(--border)} +.rm{flex:1;min-width:0} +.rn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rs{color:var(--muted);font-size:13px} +.cmt{width:100%;margin-top:6px;border:1px solid transparent;background:var(--surface-2);border-radius:8px;padding:6px 10px;font-size:13px} +.cmt:disabled{background:transparent;padding-left:0} +.row .bar{margin-top:8px} +.acts{display:flex;gap:6px;flex:none} +.drop{flex:1;margin:12px;border:2px dashed var(--border);border-radius:16px;display:grid;place-items:center;text-align:center;color:var(--muted);cursor:pointer;padding:20px;transition:border-color .15s,background .15s} +.drop:hover{border-color:var(--accent);background:var(--accent-soft)} +.drop .big{display:grid;place-items:center;width:64px;height:64px;border-radius:18px;background:var(--accent-soft);color:var(--accent-ink);margin:0 auto 14px} +.drop strong{display:block;color:var(--text);font-size:17px;margin-bottom:4px} + +.drop-strip{flex:none;display:none;align-items:center;justify-content:center;gap:10px;margin:4px 12px 12px;padding:12px;border:2px dashed var(--border);border-radius:12px;color:var(--muted);font-size:14px;font-weight:600;cursor:pointer;transition:border-color .15s,background .15s} +.drop-strip:hover,.drop-strip.over{border-color:var(--accent);background:var(--accent-soft);color:var(--accent-ink)} +.drop-overlay{position:fixed;inset:0;z-index:150;display:none;align-items:center;justify-content:center;background:rgba(40,60,160,.28);backdrop-filter:blur(2px);pointer-events:none} +.drop-overlay.on{display:flex} +.drop-overlay .box{display:grid;gap:6px;justify-items:center;border:3px dashed var(--accent);border-radius:24px;background:var(--surface);padding:36px 56px;text-align:center;box-shadow:var(--shadow);color:var(--muted)} +.drop-overlay .box strong{font-size:20px;color:var(--text)} +.drop-overlay .big{display:grid;place-items:center;width:68px;height:68px;border-radius:20px;background:var(--accent-soft);color:var(--accent-ink);margin-bottom:6px} +@media (hover:none){.drop-strip{display:none!important}} +.prog-card{flex:none;padding:20px} +.prog-top{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px} +.prog-top .pct{font-size:30px;font-weight:800;letter-spacing:-.02em} +.prog-card .bar{height:10px} +.done-card{flex:none;padding:20px} +.done-top{display:flex;gap:14px;align-items:center} +.okc{width:44px;height:44px;border-radius:50%;background:var(--ok-soft);color:var(--ok);display:grid;place-items:center;flex:none} +.done-top h2{font-size:19px} +.share{display:flex;gap:8px;margin-top:16px} +.share input{flex:1;font-family:ui-monospace,Consolas,monospace;font-size:13.5px;background:var(--surface-2)} +.kv{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:16px 0 0} +.kv div{background:var(--surface-2);border-radius:10px;padding:10px 12px} +.kv dt{font-size:12px;color:var(--muted);font-weight:600} +.kv dd{margin:2px 0 0;font-weight:600;font-size:14px;display:flex;align-items:center;gap:6px;flex-wrap:wrap} +.kv code{font-family:ui-monospace,Consolas,monospace} +.done-acts{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px} + +/* ---- 다운로드 ---- */ +.dl-head{padding:20px;display:flex;gap:16px;align-items:center;flex-wrap:wrap} +.dl-head h1{font-size:20px;letter-spacing:-.01em} +.dl-head .meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} +.dl-head .grow{flex:1;min-width:220px} +.notice{margin-top:14px;display:flex;gap:10px;align-items:center;padding:10px 14px;border-radius:12px;background:var(--ok-soft);color:var(--ok);font-size:14px;font-weight:600} +.dl-list{margin-top:14px} +.center-card{max-width:420px;margin:48px auto;padding:28px;text-align:center} +.center-card .big{width:56px;height:56px;border-radius:16px;margin:0 auto 14px;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent-ink)} +.center-card h2{font-size:19px;margin-bottom:6px} +.err{color:var(--danger);font-size:13px;font-weight:600;margin-top:8px;min-height:20px} + +/* ---- 관리자 ---- */ +.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px} +.stat{padding:14px 16px} +.stat .k{font-size:12px;color:var(--muted);font-weight:600} +.stat .v{font-size:22px;font-weight:800;letter-spacing:-.02em;margin-top:2px} +.tools{display:flex;gap:10px;margin:16px 0 12px;flex-wrap:wrap} +.tools .s{position:relative;flex:1;min-width:200px} +.tools .s .ic{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--muted)} +.tools .s input{padding-left:34px} +.tools select{border:1px solid var(--border);background:var(--surface);border-radius:10px;padding:9px 12px;font-size:14px} +table{width:100%;border-collapse:collapse;font-size:14px} +th{text-align:left;font-size:12px;color:var(--muted);font-weight:700;padding:10px 14px;border-bottom:1px solid var(--border);white-space:nowrap} +td{padding:11px 14px;border-bottom:1px solid var(--border);vertical-align:middle;white-space:nowrap} +tr.b{cursor:pointer} +tr.b:hover td{background:var(--surface-2)} +tr.frow td{background:var(--surface-2)} +.mono{font-family:ui-monospace,Consolas,monospace;font-size:13px} +td.r,th.r{text-align:right} +.tw{overflow-x:auto} +.tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:16px} +.tabs button{background:none;border:0;padding:10px 14px;font-weight:700;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;font-size:14px} +.tabs button.on{color:var(--accent-ink);border-color:var(--accent)} +.set{max-width:720px} +.set .card{padding:20px;margin-bottom:16px} +.set h2{font-size:16px;margin-bottom:14px} +.fld{margin-bottom:16px} +.fld:last-child{margin-bottom:0} +.fld .help{font-size:12.5px;color:var(--muted);margin-top:6px} +.logo-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap} +.logo-prev{width:76px;height:76px;border:1px dashed var(--border);border-radius:16px;display:grid;place-items:center;background:var(--surface-2);flex:none} +.logo-prev img{width:52px;height:52px;object-fit:contain} +.pv-hdr{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--surface);font-weight:800;font-size:23px;letter-spacing:-.015em} +.pv-hdr img{width:38px;height:38px;object-fit:contain} +.row2{display:grid;grid-template-columns:1fr 140px;gap:12px} +.chev{display:inline-block;transition:transform .15s} +tr.b.open .chev{transform:rotate(90deg)} + +/* ---- 모달 / 토스트 ---- */ +.modal-bg{position:fixed;inset:0;background:rgba(8,10,16,.6);display:none;align-items:center;justify-content:center;z-index:100;padding:16px} +.modal-bg.on{display:flex} +.modal{background:var(--surface);border-radius:16px;max-width:540px;width:100%;max-height:90dvh;display:flex;flex-direction:column;box-shadow:0 24px 60px rgba(0,0,0,.35);overflow:hidden} +.modal.wide{max-width:880px} +.mh{flex:none;display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border)} +.mh h3{flex:1;font-size:16px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.mb{flex:1;min-height:0;overflow:auto;padding:18px} +.mf{flex:none;padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;align-items:center} +.pv{display:grid;place-items:center;min-height:320px;background:var(--surface-2);border-radius:12px;padding:12px} +.pv svg{max-width:100%;height:auto;border-radius:8px} +.paper{background:#fff;color:#222;width:min(420px,100%);aspect-ratio:210/297;border-radius:4px;box-shadow:0 4px 18px rgba(0,0,0,.2);padding:36px 32px;font-size:11px} +.paper h4{margin:0 0 14px;font-size:16px} +.paper i{display:block;height:7px;background:#e6e8ec;border-radius:3px;margin-bottom:9px} +.pv:has(pre.txt){place-items:start stretch} +pre.txt{margin:0;width:100%;white-space:pre-wrap;font-family:ui-monospace,Consolas,monospace;font-size:13px;line-height:1.6} +.qr{display:grid;place-items:center;padding:6px} +.qr canvas{width:220px;height:220px;image-rendering:pixelated;background:#fff;border-radius:8px;padding:10px} +.my-item{display:flex;gap:12px;align-items:center;padding:12px 0} +.my-item+.my-item{border-top:1px solid var(--border)} +.note{font-size:12.5px;color:var(--muted);display:flex;gap:6px} +.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,20px);background:#1b2030;color:#fff;padding:10px 18px;border-radius:99px;font-size:14px;font-weight:600;opacity:0;pointer-events:none;transition:.2s;z-index:200} +.toast.on{opacity:1;transform:translate(-50%,0)} + +@media (max-width:760px){ + .ctl{grid-template-columns:1fr;gap:18px} + .kv{grid-template-columns:1fr} + .stats{grid-template-columns:repeat(2,1fr)} +} +@media (max-width:640px){ + .hdr .in{padding:10px 14px} + .wrap{padding:14px} + .btn .hide-m{display:none} + .ctl-top{flex-wrap:wrap} + .ctl-top .sum{display:none} + .ctl-top .btn.up{flex:1} + .ctl-top .warn{flex-basis:100%;order:3} + .ctl-toggle{display:flex} + .ctl.collapsed{display:none} + .row{gap:10px} + .acts .btn .hide-m{display:none} + .dl-all{width:100%} + .dl-all .btn{flex:1} +} + +/* ---- pages (added for the app) ---- */ +.view{display:flex;flex-direction:column;height:100dvh} +.list-b.is-empty{display:flex;padding:0} +.rerr{color:var(--danger);font-size:13px;font-weight:600;margin-top:4px} +.prog-msg{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:10px 12px;border-radius:10px;background:var(--warn-soft);color:var(--warn);font-size:14px;font-weight:600} +.prog-msg.bad{background:var(--danger-soft);color:var(--danger)} +.tile-s{width:32px;height:32px;font-size:9px;border-radius:8px} +.center-card .big.danger{background:var(--danger-soft);color:var(--danger)} +.center-card input{text-align:center} +.pv img{max-height:68vh} +.pv video{max-width:100%;max-height:68vh;border-radius:8px;background:#000} +.pv audio{width:100%} +.pv iframe{width:100%;height:68vh;border:0;border-radius:8px;background:#fff} +.pv.text{place-items:start stretch} +.pv-fail{text-align:center;display:grid;gap:14px;justify-items:center} +.qr img{width:240px;height:240px;border-radius:8px;image-rendering:pixelated} +.error-view .wrap{padding-top:36px} +.error-view .center-card .btn{margin-top:14px} diff --git a/app/vite.config.js b/app/vite.config.js new file mode 100644 index 0000000..bb842b0 --- /dev/null +++ b/app/vite.config.js @@ -0,0 +1,47 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +// pdf.js needs font data to draw pdfs which do not embed their fonts (and CMaps for +// Korean/Chinese/Japanese text). Without it rendering such a pdf takes very long. +// The files are only fetched when a pdf is selected, and only the ones that are needed. +const pdfAssets = () => ({ + name: 'pdfjs-assets', + apply: 'build', + closeBundle() { + const out = path.resolve(here, '../public/app'); + for (const dir of ['standard_fonts', 'cmaps']) { + fs.cpSync(path.resolve(here, 'node_modules/pdfjs-dist', dir), path.join(out, dir), { recursive: true }); + } + }, +}); + +// The server renders a thin html shell (public/pug/page.pug) which loads +// app/main.js and app/main.css. Fixed file names keep that shell independent +// of the build; heavy parts (pdf.js) are separate chunks loaded on demand. +export default defineConfig({ + base: './', + plugins: [vue(), pdfAssets()], + build: { + outDir: path.resolve(here, '../public/app'), + emptyOutDir: true, + cssCodeSplit: false, + target: 'es2022', + rollupOptions: { + input: path.resolve(here, 'src/main.js'), + output: { + entryFileNames: 'main.js', + chunkFileNames: 'chunks/[name]-[hash].js', + assetFileNames: (info) => { + const name = (info.names && info.names[0]) || info.name || ''; + return name.endsWith('.css') ? 'main.css' : 'assets/[name]-[hash][extname]'; + }, + }, + }, + chunkSizeWarningLimit: 1500, + }, +}); diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..c542ed4 --- /dev/null +++ b/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('./app'); diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..078563c --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,29 @@ +# Dodo Transfer: docker compose example +# +# 1. cp .env.example .env and set the two passwords in it +# 2. docker compose -f compose.example.yaml up -d --build +# +# The image is built from this repository (the frontend is built inside the build). +# Everything the app stores lives in the /data volume: the uploaded files and the +# settings of the admin page (name, logo), so a re-created container keeps them. +services: + dodo-transfer: + build: . + image: dodo-transfer:latest + container_name: dodo-transfer + user: "1000:1000" # same uid/gid as the owner of the data folder + restart: unless-stopped + ports: + - "127.0.0.1:3377:3000" # not exposed directly, only the nginx of this server reaches it + env_file: + - .env # PSITRANSFER_ADMIN_PASS, PSITRANSFER_UPLOAD_PASS + environment: + PSITRANSFER_BASE_URL: "/sharing" + PSITRANSFER_REQUIRE_BUCKET_PASSWORD: "true" # every upload needs a password, except one-time downloads + PSITRANSFER_TRUST_PROXY: "172.16.0.0/12" + PSITRANSFER_MAX_AGE: "1209600" # 14 days, no file lives longer + PSITRANSFER_DEFAULT_RETENTION: "86400" + PSITRANSFER_RETENTIONS: '{"one-time":"1회 다운로드","3600":"1시간","86400":"1일","259200":"3일","432000":"5일","604800":"7일","1209600":"2주"}' + PSITRANSFER_APP_NAME: "AJIN Transfer" # default name, can be changed on the admin page + volumes: + - /storage/sharing/files:/data diff --git a/config.dev.js b/config.dev.js new file mode 100644 index 0000000..7122bba --- /dev/null +++ b/config.dev.js @@ -0,0 +1,25 @@ +// Used when NODE_ENV=dev, e.g. NODE_ENV=dev node app.js +module.exports = { + "accessLog": 'dev', + "retentions": { + "one-time": "one time download", + "60": "1 Minute", + "300": "5 Minutes", + "3600": "1 Hour", + "21600": "6 Hours", + "86400": "1 Day", + "259200": "3 Days", + "432000": "5 Days", + "604800": "7 Days", + "1209600": "2 Weeks" + }, + "defaultRetention": "3600", + "adminPass": "admin", + "uploadPass": false, + "baseUrl": '/', + "uploadAppPath": '/', + // "maxFileSize": Math.pow(2, 20) * 15, + // "maxBucketSize": Math.pow(2, 20) * 20, + // "sslKeyFile": './tmp/cert.key', + // "sslCertFile": './tmp/cert.pem', +}; diff --git a/config.js b/config.js new file mode 100644 index 0000000..72a964e --- /dev/null +++ b/config.js @@ -0,0 +1,152 @@ +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs-promise'); + +// Default Config +// Do not edit this, generate a config..js for your NODE_ENV +// or use ENV-VARS like PSITRANSFER_PORT=8000 +const config = { + "uploadDir": path.resolve(__dirname + '/data'), + // admin settings (name, logo, ...) are stored here, default: /.settings + "settingsDir": '', + // set to serve the app from a sub-path + "baseUrl": '/', + // use to set custom upload url (subfolder to baseUrl) + "uploadAppPath": '/', + "iface": '0.0.0.0', + // set to false to disable HTTP + "port": 3000, + // HTTPS, set all 3 values to enable + "sslPort": 8443, + "sslKeyFile": false, + "sslCertFile": false, + // Force redirect to https + // can be true or a specific url like https://example.com:8443 + // keep empty to disable + "forceHttps": '', + // Use trust proxy to show real IP on logs when reverse proxy are used + // see https://expressjs.com/en/guide/behind-proxies.html for options + "trustProxy": '', + // retention options in seconds:label + "retentions": { + "one-time": "one time download", + "3600": "1 Hour", + "21600": "6 Hours", + "86400": "1 Day", + "259200": "3 Days", + "432000": "5 Days", + "604800": "7 Days", + "1209600": "2 Weeks", + "2419200": "4 Weeks", + "4838400": "8 Weeks" + }, + // retentions shown as buttons, all others are offered in a list box + "quickRetentions": ['one-time', '86400', '259200', '432000', '604800'], + // admin password, set to false to disable /admin page + // to enable /admin page set your password like this: + // "adminPass": "YourSecurePassword", + "adminPass": false, + // upload password, set to false to disable + "uploadPass": false, + // lifetime of the login token issued after a successful upload login (seconds) + // the token is renewed on every use + "tokenTtl": 60 * 60 * 24 * 7, + // secret to sign login tokens. When empty it is derived from uploadPass and adminPass, + // so changing one of the passwords invalidates all issued tokens + "tokenSecret": '', + // a bucket-password is mandatory, except for one-time downloads + "requireBucketPassword": true, + "defaultRetention": "604800", + // expire every file after maxAge (eg never downloaded one-time files) + "maxAge": 3600 * 24 * 75, // 75 days + // maximum file-size for text previews in byte + "maxPreviewSize": Math.pow(2, 20) * 2, // 2MB + "mailTemplate": 'mailto:?subject=파일 전달&body=다운로드 링크: %%URL%%', + // see https://github.com/expressjs/morgan + // set to false to disable logging + "accessLog": ':date[iso] :method :url :status :response-time :remote-addr', + // event webhooks + // invokes an HTTP POST to a url whenever a file is downloaded + // for more info, see the webhooks section of docs/configuration.md + "fileDownloadedWebhook": null, + "fileUploadedWebhook": null, + // Fallback language + "defaultLanguage": "ko", + // Limit upload size + "maxFileSize": null, // Math.pow(2, 30) * 2, // 2GB + "maxBucketSize": null, // Math.pow(2, 30) * 2, // 10GB + "plugins": ['file-downloaded-webhook', 'file-uploaded-webhook'], + // Disable the QR code button for download url sharing, set to true to disable + "disableQrCode": false, + // Defaults for the branding, each of them can be changed on the admin settings page + "appName": 'Dodo Transfer', + "appOwner": '아진산업(주) 전장선행개발팀', + "appFromYear": 2026, +}; + +// Load NODE_ENV specific config +const envConfFile = path.resolve(__dirname, `config.${ process.env.NODE_ENV }.js`); +if (process.env.NODE_ENV && fsp.existsSync(envConfFile)) { + Object.assign(config, require(envConfFile)); +} + +// Load config from ENV VARS +const BOOLEAN_KEYS = ['requireBucketPassword', 'disableQrCode']; +let envName; +for (let k in config) { + envName = 'PSITRANSFER_' + k.replace(/([A-Z])/g, $1 => "_" + $1).toUpperCase(); + if (process.env[envName]) { + const raw = process.env[envName]; + if (typeof config[k] === 'number') { + config[k] = parseInt(raw, 10); + } else if (BOOLEAN_KEYS.includes(k)) { + // other keys that default to false (passwords, ssl files, ...) take a string + config[k] = ['true', '1', 'yes', 'on'].includes(raw.trim().toLowerCase()); + } else if (Array.isArray(config[k])) { + config[k] = raw.split(',').map(s => s.trim()).filter(Boolean); + } else if (config[k] === null) { + // null defaults are numbers (limits) or strings (urls) + try { config[k] = JSON.parse(raw); } catch (e) { config[k] = raw; } + } else if (typeof config[k] === 'object') { + config[k] = JSON.parse(raw); + } else { + config[k] = raw; + } + } +} + +if (!config.baseUrl.endsWith('/')) config.baseUrl = config.baseUrl + '/'; +if (!config.uploadAppPath.endsWith('/')) config.uploadAppPath = config.uploadAppPath + '/'; + +config.uploadAppPath = config.baseUrl.substr(0, config.baseUrl.length - 1) + config.uploadAppPath; + +config.uploadDir = path.resolve(config.uploadDir); +if (!config.settingsDir) config.settingsDir = path.join(config.uploadDir, '.settings'); + +// Retention sanity: ignore unknown keys instead of showing broken buttons +config.quickRetentions = config.quickRetentions.filter(key => { + const known = Object.prototype.hasOwnProperty.call(config.retentions, key); + if (!known) console.warn(`config: quickRetentions entry "${ key }" is not in retentions, ignored`); + return known; +}); +if (!Object.prototype.hasOwnProperty.call(config.retentions, String(config.defaultRetention))) { + const fallback = Object.keys(config.retentions).find(k => k !== 'one-time'); + console.warn(`config: defaultRetention "${ config.defaultRetention }" is not in retentions, using "${ fallback }"`); + config.defaultRetention = fallback; +} + +// Load language files: English is the base, every other language overrides it +const langDir = path.resolve(__dirname, 'lang'); +const base = require('./lang/en'); +config.languages = {}; +fs.readdirSync(langDir).forEach(file => { + if (!file.endsWith('.js')) return; + const code = file.replace(/\.js$/, ''); + config.languages[code] = code === 'en' ? base : { ...base, ...require(`./lang/${ code }`) }; +}); +if (!config.languages[config.defaultLanguage]) { + console.warn(`config: defaultLanguage "${ config.defaultLanguage }" not found, using "en"`); + config.defaultLanguage = 'en'; +} + +module.exports = config; diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..6a0b5db --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,281 @@ +# 설계 기록 + +> **이 문서는 구현 전에 정리한 설계 기록입니다. 모두 구현되었습니다.** 현재 동작과 설정은 +> [README](../README.md)와 [configuration.md](configuration.md)를 보세요. 구현하면서 바뀐 점은 아래와 같습니다. +> +> * **이름과 로고의 기본값:** `Dodo Transfer`와 임의로 만든 도도새 아이콘(`public/assets/logo.svg`)입니다. +> AJIN Transfer와 아진산업 로고(`docs/brand/ajin-logo.png`)는 관리자 설정이나 환경변수 +> `PSITRANSFER_APP_NAME`으로 쓸 수 있습니다. (항목 2, 12) +> * **PDF 썸네일:** pdf.js 약 1.7MB(워커 포함), 글꼴과 CMap 데이터 약 2.3MB가 PDF를 고를 때만 내려받아집니다. (항목 6) +> * `docs/mockup/index.html`은 디자인을 정할 때 쓴 목업입니다 (AJIN 로고와 이름 기준). +> * 업로드 인증의 `x-passwd` 원문 헤더는 스크립트용으로 계속 받습니다. (항목 1) + +PsiTransfer(BSD-2-Clause) 기반 포크. + +상태 표기: **확정** / 보류 / 최종 단계 + +--- + +## 1. 업로드 인증 토큰 — 확정 + +업로드 비밀번호(`uploadPass`)를 매번 입력하지 않도록 서버 발급 토큰을 localStorage에 저장한다. + +- 방식: 서버 발급 서명 토큰 (`base64url({exp}).HMAC`), 서버 저장 상태 없음 +- 로그인: `POST /login` → `{ token, exp }`. 실패 시 지연 후 403 (무차별 대입 완화) +- 요청 헤더: `Authorization: Bearer `. 검증은 상수 시간 비교 +- 유효 기간: 7일, 사용할 때마다 연장 (접속하여 `config.json`을 받을 때 새 토큰 발급, 업로드 청크마다는 갱신하지 않음). 기간은 설정 항목으로 변경 가능 +- 서명 키: `adminPass`와 `uploadPass`를 재료로 파생 → 둘 중 하나를 바꾸면 기존 토큰 전부 무효. + `adminPass`가 없으면 `uploadPass`만 사용. 환경변수 `PSITRANSFER_TOKEN_SECRET`이 있으면 그 값 우선 +- 도커: 키가 설정에서 파생되므로 볼륨 없이 재시작/재생성 후에도 토큰 유지 +- 범위: 업로드만. `/admin`은 현행 유지. 로그아웃 없음 +- 화면: 저장된 토큰으로 먼저 `config.json` 요청 → 성공하면 로그인 화면 생략. 만료/불일치면 토큰 삭제 후 로그인 화면. + localStorage 사용 불가 시 메모리에만 보관 +- 영향 파일: `lib/endpoints.js`(인증부), `config.js`(설정 추가), `app/src/Upload/store/config.js`, `app/src/Upload.vue` +- 구현 직전 확인할 것: 기존 `x-passwd` 원문 헤더를 curl/스크립트용으로 계속 받을지 여부 +- 보류: 다운로드 bucket 비밀번호는 나중에 논의 + +## 2. 브랜딩 — 확정 + +- 이름: `AJIN Transfer`. 한국어 문장 안에서도 영문 그대로 사용 +- 바꿀 곳: ``(`head.pug`), `<h1>`(`upload.pug`, `download.pug`, `error.pug`, `admin.pug`), 푸터, 메일 제목(`lang/*.js`의 `mailSubject*`), 콘솔 로그(`app.js`), `package.json` +- 푸터: `© 2026–{현재연도} 아진산업(주) 전장선행개발팀` (기존 "Powered by PsiTransfer" 링크 제거). + 연도는 서버가 렌더링할 때 계산하고, 현재 연도가 2026이면 `© 2026`만 표기 +- 로고: 사용자가 제공한 이미지(256×256 PNG, 투명 배경, 노란색 `#FCB813` 원형 엠블럼)를 사용. + 인라인 SVG 대신 `public/assets/logo.png`를 `<img>`로 참조 → 교체 시 파일만 덮어쓰면 됨. 파비콘도 이 이미지에서 생성. + SVG 원본이 생기면 교체 +- 회사 색상: 노란색 (`#FCB813`). **로고에만 사용**한다. 화면 테마(버튼, 진행바 등)는 노란색으로 바꾸지 않음 +- 위 이름/로고/푸터는 **기본값**이고, 관리자 설정에서 바꿀 수 있음 (항목 12). 사용자의 로컬 서버 등 다른 환경에서도 같은 이미지를 쓰기 위함 +- 환경변수 접두사 `PSITRANSFER_*`는 유지 (도커 설정 호환) +- 라이선스: `LICENSE`와 원 저작권 고지 유지. "PsiTransfer 기반" 출처는 `README.md`/`LICENSE`에 표기 + +## 5. 업로드 설정: 보관 기간 버튼, 비밀번호 필수 조건 — 확정 + +- 보관 기간: **자주 쓰는 몇 개는 버튼, 나머지는 리스트박스(`select`)** 로 혼합. 사용자 결정. + - **버튼(확정): 1회 다운로드 / 1일 / 3일 / 5일 / 7일.** 나머지(1시간, 6시간, 2주, 4주, 8주)는 "그 외" 리스트박스 + - 기본 `retentions`에 `432000`(5일)이 없으므로 추가하고, `604800`의 라벨을 "1주일"에서 "7일"로 통일(`lang` 라벨 포함). 기본 선택은 7일 유지 + - 버튼에 올릴 키는 설정 `quickRetentions`(기본 `one-time,86400,604800`, 환경변수 `PSITRANSFER_QUICK_RETENTIONS`)로 지정 → 코드 수정 없이 변경. + 리스트박스에는 버튼에 없는 `retentions`만 나옴 + - 하나만 선택됨. 리스트박스에서 고르면 버튼 선택은 해제되고 리스트박스가 선택된 값을 표시(활성 상태로 강조). 버튼 그룹은 라디오 그룹 의미로 접근성 유지 + - 선택지 목록은 서버 설정(`retentions`)에서 받아 동적으로 그림. 라벨은 `lang.retentions[키]` 우선, 없으면 설정의 라벨. 기본 선택은 `defaultRetention`(1주일). 1회 다운로드는 시각적으로 구분 + - `defaultRetention`이나 `quickRetentions`가 `retentions`에 없는 값이면 서버 시작 시 경고하고 무시 +- 비밀번호 필수 조건: `requireBucketPassword`가 켜져 있어도 **1회 다운로드를 고르면 비밀번호 없이 업로드 가능**. 그 외 보관 기간은 필수 + (해석 확인 필요: "다 필수가 아니고 1회 다운로드면 없어도 됨" = 기본은 필수, 1회 다운로드만 예외) +- 서버 검증 추가: 현재 `requireBucketPassword`는 **프론트에서만** 검사하고 서버는 확인하지 않음. 서버 POST 처리(`endpoints.js`)에서도 + `requireBucketPassword && retention !== 'one-time' && !password`이면 400 거부 +- 화면: 1회 다운로드 선택 시 비밀번호 칸은 숨기지 않고 "선택 사항"으로 표시, 입력된 값은 유지. 랜덤 비밀번호 생성 버튼 유지 +- 영향 파일: `Settings.vue`(→ 새 컴포넌트), `Upload.vue`의 `showUploadBtn` 조건, `lib/endpoints.js`(검증), `lang/en.js`·`ko.js`(필요 시 문구) +- 영향 파일 추가: `config.js`(`quickRetentions`), `config.json` 응답에 포함(`endpoints.js`) +- 비밀번호 필수 조건 (확정): 기본은 필수, 1회 다운로드만 예외 +- **인증된 사용자는 비밀번호 없이 다운로드 (체크박스)** — 사용자 제안, 이 방식으로 진행: + - 업로드 화면에 체크박스 하나. bucket 비밀번호는 **그대로 설정**하고(필수 조건 그대로 적용), 체크하면 추가로 "사내 인증자는 비밀번호 면제"가 됨 + - 다운로드 링크를 열었을 때: 브라우저에 유효한 세션(항목 1의 업로드 인증 토큰)이 있으면 비밀번호 입력을 건너뜀. + 세션이 없거나 만료됐으면 지금처럼 bucket 비밀번호를 입력해야 함 (외부 수신자는 이쪽) + - 구현: 체크 상태를 파일 메타데이터 `internalBypass`로 저장(tus 메타데이터는 문자열만 → `"true"`). 다운로드 목록 응답(`{sid}.json`)에서 + `Authorization: Bearer` 토큰이 유효하고 `internalBypass`가 켜진 bucket이면 비밀번호 검사를 생략. 다운로드 페이지는 같은 출처라 localStorage 토큰을 그대로 읽어 헤더에 실음 + - 로그인 화면은 다운로드 페이지에 두지 않음 (세션이 없으면 비밀번호 입력으로 대체) + - 체크박스는 비밀번호가 입력된 경우에만 활성화 (비밀번호가 없는 1회 다운로드는 해당 없음). `uploadPass`가 꺼져 있어 세션 자체가 없는 환경에서는 숨김 + - 비밀번호 원문을 bucket 비밀번호로 재사용하는 안은 채택하지 않음 (토큰 방식이라 클라이언트에 원문이 없고, 외부 수신자에게 업로드 비밀번호가 노출됨) + - 파일 URL(`/files/{sid}++{uuid}`)은 현재도 비밀번호 검사를 하지 않고 UUID로 보호됨 → 같은 모델 유지. 검사는 목록(`{sid}.json`)에서만 + - 다운로드 페이지에서 유효한 토큰으로 접속하면 항목 1의 슬라이딩 연장도 같이 적용(응답으로 새 토큰 발급) + - 체크박스 기본값: **켬**. 문구: "인증된 사용자는 비밀번호 없이 다운로드" (확정). 비밀번호 칸이 비어 있으면 비활성화 +- 항목 5 확정 (미결 없음) + +## 6. 다운로드 화면 미리보기 확대 — 확정 + +현재: 눈 모양 버튼으로 모달을 열어 **이미지와 텍스트만**, **2MB 이하**, **1회 다운로드 파일은 제외**. 파일은 `Content-Disposition: attachment`로 내려가고 +저장 파일에 확장자가 없어 `Content-Type`이 `application/octet-stream`이라 이미지 외에는 브라우저 내장 뷰어가 동작하지 않음. + +- 미리보기 대상 (제안): 이미지(png/jpg/gif/webp/avif/bmp/svg), PDF, 동영상(mp4/webm), 오디오(mp3/wav/ogg/m4a), 텍스트·코드(기존 확장자 목록 유지, 필요 시 확장) +- 서버: 미리보기용 요청(`?inline=1`)에 한해 `Content-Disposition: inline`으로 내려줌. + - `Content-Type`은 **파일 이름의 확장자로 서버가 결정**한 허용 목록 값만 사용. 클라이언트가 보낸 `metadata.type`은 신뢰하지 않음 (위조 가능) + - `X-Content-Type-Options: nosniff`, 응답 CSP `sandbox` 적용 → 업로드된 HTML/SVG 안의 스크립트 실행 방지. HTML은 허용 목록에서 제외, SVG는 `<img>`로만 표시 + - 허용 목록에 없는 형식은 기존처럼 attachment(다운로드) + - 동영상/오디오 탐색(seek)을 위해 Range 요청 지원 (`res.sendFile`이 이미 지원) +- **동영상/오디오는 코덱에 따라 재생 여부가 다름** (확장자만으로 알 수 없음). 확장자가 `mp4/m4v/mov/webm/ogv`(오디오는 `mp3/wav/ogg/m4a/flac`)이면 미리보기 버튼을 표시하고, + 재생할 수 없으면(브라우저의 오류 이벤트) 모달 안에 "이 브라우저에서 재생할 수 없는 형식입니다. 다운로드해서 확인하세요." 안내와 다운로드 버튼을 표시. + 썸네일은 업로드한 브라우저가 디코딩할 수 있을 때만 생성되므로, 썸네일이 있어도 받는 PC에서는 재생이 안 될 수 있음. 서버 변환(ffmpeg)은 도입하지 않음 +- 미리보기는 **다운로드로 집계하지 않음**: `lastDownload` 갱신과 `fileDownloaded` 웹훅은 `inline` 요청에서 제외 +- 1회 다운로드 파일은 미리보기 불가 유지 (미리보기가 곧 소모가 되기 때문) +- 크기 제한: 텍스트는 메모리에 올리므로 `maxPreviewSize`(2MB) 유지. 이미지·PDF·동영상·오디오는 스트리밍이라 크기 제한을 두지 않음 (또는 별도 설정) +- UI: 라이트박스 형태 모달, 이전/다음(키보드 ←/→), 닫기(Esc), 다운로드 버튼, 파일 이름과 순번. 이미지 파일은 목록에서 미리보기 가능 표시 +- 영향 파일: `lib/endpoints.js`(inline 처리, 허용 목록), 새 `lib/mime.js`(확장자→타입 허용 목록), `PreviewModal.vue`(재작성), `Download.vue` +- **범위 결정 (사용자): 브라우저가 자체적으로 표시할 수 있는 형식만 미리보기.** Office 문서/CAD 등은 서버 변환 없이 미리보기 제외(다운로드만) +- **썸네일 (사용자 요청으로 변경)**: 목록의 파일 종류 아이콘 자리에, 미리보기가 되는 파일은 **누르지 않아도 보이는 작은 썸네일**을 표시 (이전의 "썸네일 없음" 결정을 대체). + 확장자 표시는 썸네일 왼쪽 아래 작은 뱃지, 눈 모양 뱃지가 있는 썸네일/아이콘을 누르면 미리보기 모달. 썸네일이 없는 형식은 지금처럼 색 아이콘 + - 생성 위치: **업로드하는 브라우저가 만듦** (서버에 이미지 처리 라이브러리를 추가하지 않음). 이미지는 캔버스로 약 104px 축소(webp), 텍스트는 첫 줄들을 그림으로 변환 + - 전달: 파일 업로드가 끝나면 작은 썸네일(최대 64KB)을 서버에 올림 (`/files/{sid}++{key}.thumb`). 서버는 크기와 파일 시그니처(webp/png/jpeg)만 검증하고 + `X-Content-Type-Options: nosniff`로 이미지 형식으로만 내려줌. 파일 삭제/만료 시 썸네일도 함께 삭제 (`lib/store.js`의 삭제 대상에 추가) + - 업로드 화면: 파일을 고르면 즉시 썸네일 표시 (올리기 전에 확인 가능, 클릭하면 실제 파일을 미리보기) + - 다운로드 화면/관리자 화면: 서버에 저장된 썸네일을 표시. API(curl 등)로 올려 썸네일이 없는 파일은 아이콘으로 대체 + - 1회 다운로드 파일은 썸네일도 표시하지 않음 (미리보기 불가 원칙과 일치) + - **PDF 첫 장 썸네일 (확정)**: `pdf.js`를 프론트에 포함(약 1MB, Apache-2.0이라 라이선스 고지 파일 포함). 외부 CDN을 쓰지 않고 `public/app`에 함께 배포. + PDF를 고른 경우에만 늦게 불러오므로(lazy load) PDF를 안 쓰면 추가 다운로드 없음. 첫 장을 작게 그려 webp로 변환. + 안전장치: 50MB 초과 PDF, 암호가 걸린 PDF, 읽기 실패나 5초 초과 시 썸네일을 포기하고 아이콘으로 표시 (업로드 자체에는 영향 없음) + - **동영상 첫 프레임 썸네일 (기본 포함으로 제안)**: 브라우저 `<video>`로 첫 프레임을 캔버스에 그려 변환. 라이브러리 불필요. 이의가 없으면 포함 + - 목업에는 이미지와 텍스트 썸네일만 실제로 생성되고, PDF와 동영상은 샘플 그림으로 대체되어 있음 (구현 단계에서 실제 생성) +- 항목 6 확정. 미리보기는 **버튼으로 열고, 지원하는 형식의 파일에만 버튼을 표시**. 새 창은 쓰지 않음 (항목 8) + +## 7. 업로드 화면 흐름 — 확정 (모바일 처리는 제안) + +- 화면을 **상단 고정 영역(스크롤되지 않음)** 과 **파일 목록 영역**으로 나눔. 파일이 늘면 목록 영역만 아래로 길어지고 스크롤됨 +- 상단 고정 영역은 **세로 공간을 최소화**하도록 두 줄로 압축 (목업 검토 후 수정, 약 160px): + 1줄: `파일 추가` · 요약(파일 수, 크기, 보관 기간) · 필요한 경우 경고 · **업로드 버튼(오른쪽 끝)** + 2줄: 왼쪽 보관 기간(라벨 옆에 버튼 + 그 외 리스트박스), 오른쪽 비밀번호 입력 + 생성 버튼, 그 아래 "인증된 사용자는 비밀번호 없이 다운로드" 체크박스. + 비밀번호 필수/선택 표시는 별도 태그 대신 입력창 안내 문구(`비밀번호 (필수)` / `비밀번호 (선택)`)로 표시. + 비밀번호 입력창 오른쪽 안에 **지우기(X) 버튼** (값이 있을 때만 표시). 지우면 입력창에 포커스가 가고 체크박스는 비활성화, 필수 보관 기간이면 업로드 버튼이 막히고 "비밀번호를 입력하세요" 표시 + 업로드 버튼이 파일 목록 위에 있어서 파일이 아무리 많아도 항상 보임 +- 파일 목록 영역: 비어 있으면 큰 드롭 안내, 파일이 있으면 이름/크기/코멘트/진행바/삭제. 화면 어디에 놓아도 파일 추가 +- **드래그앤드롭을 알아볼 수 있게** (사용자 지적: 버튼만 있으면 드롭이 되는지 알 수 없음): + - 파일이 있을 때도 목록 카드 아래에 **항상 보이는 점선 드롭 영역** "파일을 여기로 끌어다 놓거나 클릭해서 추가" (클릭하면 파일 선택, 목록 스크롤과 무관하게 고정) + - 파일을 끌고 화면에 들어오면 **전체 화면 오버레이** "여기에 놓아 파일 추가" 표시 (놓으면 추가, 벗어나면 사라짐) + - Ctrl+V 붙여넣기로 클립보드 이미지/파일 추가 (이미지는 `붙여넣기_시분초.png`로 이름 부여) + - 업로드 중/완료 후에는 파일 추가 불가 → 드롭하면 "업로드 중에는 파일을 추가할 수 없습니다" 안내 + - 터치 기기(드래그 불가)에서는 점선 영역을 숨기고 `파일 추가` 버튼만 사용 +- 업로드 중: 상단 고정 영역에 전체 진행률 표시, 개별 진행률은 목록의 각 파일에 표시. 업로드 중 파일 추가는 지금처럼 불가 +- 완료: 상단 고정 영역이 공유 영역(항목 9)으로 바뀜. 목록은 결과로 남음 +- 모바일(제안): 상단 영역이 길어 화면을 가리므로 좁은 화면에서는 설정을 접고 펼 수 있는 패널로 두고 **업로드 버튼만 고정** + +## 8. 다운로드 화면 — 확정 + +- 파일마다 버튼: 미리보기(지원 형식에만 표시), 다운로드, 링크 복사. **행 클릭은 아무 동작도 하지 않음** (버튼만 사용. 실수로 다운로드되는 것 방지) +- **새 창 금지**: 미리보기는 페이지 안 모달. 현재 업로드 완료 후 QR 코드가 `window.open`으로 새 탭에 열리는데 이것도 페이지 안 모달로 변경. + 다운로드는 같은 탭에서 파일 저장(`<a download>`), zip/tar.gz 전체 다운로드는 현행 유지(페이지 이동 없이 저장됨) +- **만료일 표시**: 서버가 `{sid}.json` 응답에 bucket 기준 가장 이른 만료 시각과 파일별 만료 시각(`expiresAt`)을 포함. 계산은 `lib/db.js`의 만료 정리(gc)와 + 같은 함수를 공유해 화면과 실제 삭제 시점이 어긋나지 않게 함. + 표시: `2026-11-12 18:30 만료 (7일 남음)`, 1회 다운로드는 "1회 다운로드 후 삭제됩니다". 24시간 이내면 강조. 날짜는 브라우저 시간대로 표시 + +## 9. 업로드 완료 후 공유 — 확정 + +- **"링크와 비밀번호 복사" 버튼** 하나 추가 (비밀번호가 있을 때만). 복사 내용 (제안): + `AJIN Transfer 파일 공유` / `다운로드 링크: {URL}` / `비밀번호: {PW}` / `만료: {날짜}` +- 기존 QR 코드, 이메일, 링크 복사 버튼은 유지. 이메일 본문에는 비밀번호를 넣지 않음 (링크와 비밀번호를 같은 메일로 보내면 비밀번호의 의미가 줄어듦) + +## 10. 내 업로드 목록 — 확정 (브라우저 로컬 저장) + +사내 SSO가 없어 개인 식별은 하지 않음. 서버 변경 없이 **이 브라우저의 localStorage에만 저장**. (사용자 결정) + +- 업로드가 완료되면 항목을 기록: sid, 링크, 파일 이름 목록, 총 크기, 생성 시각, 보관 기간, 만료 시각, 비밀번호 여부 +- **비밀번호 원문은 저장하지 않음** (localStorage에 평문 비밀번호를 남기지 않기 위함). 따라서 목록에서의 복사는 링크만 +- 위치: 업로드 화면 상단의 "내 업로드" 버튼 → 페이지 안 모달/패널(새 창 아님) +- 각 항목: 링크 복사, 열기(같은 탭), 목록에서 제거. 최신순 +- 상태 표시: 저장된 만료 시각이 지났으면 "만료됨". 아직 유효해 보이는 항목은 `GET /{sid}.json` 응답 코드로 확인 (404면 "삭제됨/만료됨", 401·200이면 존재)해서 서버에서 이미 사라진 것(1회 다운로드 소진, 관리자 삭제)을 반영. + 확인 요청에 비밀번호는 필요 없음 +- 정리: 만료된 항목은 30일 뒤 자동 제거, 최대 100개 유지 +- localStorage를 쓸 수 없는 환경에서는 목록 기능을 숨김 +- 한계(화면에 안내 문구로 표시): 이 브라우저에서 올린 것만 보이고, 다른 PC나 브라우저 데이터 삭제 시 사라짐 +- 관리자 화면의 업로더 표시는 하지 않음 (식별 불가). 항목 11의 "식별 가능 시 업로더"는 해당 없음 + +## 11. 관리자 페이지 — 확정 (인증 방식은 현행 유지) + +사람들이 올린 것을 보고 관리: 조회, 다운로드, 삭제. + +- 목록: bucket → 파일. 생성일, 마지막 다운로드, 만료, 크기, 비밀번호 여부. 검색, 정렬, 전체 사용 용량 표시 (업로더는 식별 불가라 표시하지 않음) +- 다운로드: 파일 단위, bucket zip. 관리자 권한이므로 bucket 비밀번호 없이 받을 수 있음. + **관리자 다운로드는 1회 다운로드 파일을 소모하지 않고**, 마지막 다운로드 시각 갱신과 웹훅도 하지 않음 +- 브라우저 링크로 받기 위해 헤더 인증 대신 **60초짜리 서명 URL**을 발급(`POST /admin/download-link`). (`<a href>`는 `x-passwd` 헤더를 실을 수 없음) +- 삭제: 파일 단위, bucket 단위. 페이지 안 확인 모달 후 파일과 메타데이터를 즉시 삭제. 복구 불가 + - 파일 하나를 지우면 받는 사람 화면에서도 즉시 사라짐 (새로 열면 남은 파일만 표시, 마지막 파일을 지우면 bucket이 사라져 "링크를 찾을 수 없음") + - 확인 모달에 "받는 사람 화면에서도 즉시 사라집니다"를 명시 + - 삭제 전에 이미 페이지를 열어 둔 받는 사람이 지워진 파일을 누르면 "삭제되었거나 만료된 파일입니다" 안내를 보여주고 목록을 새로 불러옴. + 전체 받기 주소는 파일 목록으로 계산되므로 삭제 후 이전 주소는 거부됨 (같은 안내 + 새로고침) +- 미리보기: 다운로드 화면과 같은 모달 재사용 +- 현재 비밀번호가 걸린 bucket은 관리자 목록에서 key/url을 숨기는데, 이 동작을 제거 (관리자는 모두 접근) +- 인증: 현행 유지 (항목 1은 업로드만 대상). 비밀번호 비교만 상수 시간 비교로 변경 +- 화면은 "파일 관리" / "설정" 두 탭 (설정은 항목 12) +- 영향 파일: `lib/endpoints.js`에서 관리자 라우트를 `lib/admin.js`로 분리, `Admin.vue` 재작성 + +## 12. 서비스 이름·로고 관리자 설정 — 제안 (사용자 요청, 확정 대기) + +사용자의 로컬 서버 등 여러 곳에서 쓸 수 있도록 `AJIN Transfer`와 로고를 관리자 설정에서 바꿀 수 있게 한다. + +- 설정 항목: 서비스 이름(1~40자), 로고 이미지, 하단 저작권 표기(소유자 문구 + 시작 연도, 현재 연도가 크면 `2026–현재` 범위로 자동 표시) +- 저장: `/data` 볼륨 안의 숨김 폴더(`/data/.settings/settings.json`, 로고 파일). 별도 볼륨 없이 컨테이너를 다시 만들어도 유지. + `lib/db.js`가 `/data` 아래 폴더를 bucket으로 읽으므로 이 폴더는 import에서 명시적으로 제외 +- 우선순위: 관리자 설정 > 환경변수/설정 파일 기본값(`PSITRANSFER_APP_NAME` 등) > 내장 기본값(AJIN Transfer, 번들 로고, 항목 2의 푸터) +- 적용 범위: 상단 로고와 이름, 브라우저 탭 제목과 파비콘, 푸터, 오류 페이지, 메일 제목(`lang`의 `mailSubject*`를 `{appName}` 치환으로 변경), "링크와 비밀번호 복사" 문구, 서버 콘솔 로그 +- 로고 규칙: PNG / SVG / WebP, 최대 1MB. 확장자가 아니라 파일 시그니처(매직 바이트)로 형식 검증. 정사각형 권장. "기본 로고로 되돌리기" 제공 +- 보안: 관리자만 변경 가능. SVG는 `<img>`로만 사용하고 응답에 `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff` 적용. 이름은 항상 이스케이프 +- 서빙: `GET /assets/brand-logo`(설정 로고 또는 기본 로고, 내용 해시로 `?v=` 캐시 갱신) 하나로 헤더 로고와 파비콘 모두 사용 → 로고를 바꿔도 캐시 때문에 안 바뀌는 문제 방지 +- API: `GET /brand.json`(공개: 이름, 로고 주소, 푸터), `PUT /admin/settings`, `POST /admin/settings/logo`(이미지 본문), `DELETE /admin/settings/logo`(기본값 복원) +- 첫 화면 깜빡임 방지: pug 껍데기에서 이름/제목/푸터를 서버가 직접 채우고 SPA는 그 값을 읽음 +- 관리자 비밀번호(`adminPass`)가 꺼져 있으면 관리자 페이지가 없으므로 환경변수 기본값만 사용 +- 저장 전 미리보기와 "되돌리기"는 목업에 반영됨 (설정 탭) +- 영향 파일: `lib/admin.js`(신규), 새 `lib/settings.js`, `lib/db.js`(import 제외), `config.js`, `public/pug/*.pug`, `lang/*.js`, 프론트 관리자 화면 + +--- + +## 4. 프론트엔드 플랫폼과 디자인 방향 — 제안 (확정 대기) + +사용자가 Vue 3로 바꿔도 된다고 했고, 최근의 예쁜 디자인으로 바꾸길 원함. 서버 API는 항목 1 외에는 바꾸지 않는다. + +기술 스택 (제안): + +| 영역 | 현재 | 변경 | +|---|---|---| +| 프레임워크 | Vue 2.7 (EOL) | Vue 3 (`<script setup>`) | +| 빌드 | webpack 5 + vue-loader 15 + babel | Vite | +| 상태 관리 | Vuex 3 | Pinia | +| 템플릿 | `<template lang="pug">` | 일반 HTML 템플릿 | +| 스타일 | Bootstrap 3 (`styles.css`) | 직접 작성한 CSS + 디자인 토큰(CSS 변수). 프레임워크 없음 | +| 아이콘 | vue-awesome | lucide (필요한 것만 번들) | +| 업로드 | tus-js-client 2 | tus-js-client 최신 | +| 드래그앤드롭 | drag-drop 패키지 | 브라우저 기본 API | +| QR | @nuintun/qrcode | 유지 | +| 대상 브라우저 | IE11 포함 | 최신 브라우저만 (IE 폴리필 제거) | +| 언어 | JavaScript | JavaScript 유지 | + +유지하는 것: +- 빌드 결과 위치(`public/app`)와 파일 이름(`common.js`, `upload.js`, `download.js`, `admin.js`)을 그대로 두면 pug 껍데기와 [Dockerfile](Dockerfile)의 멀티스테이지 빌드(`frontend` 단계)는 구조 변경이 없음. 이미지 빌드 중에 프론트가 빌드되므로 도커 친화적 +- 서버가 `lang.json`, `config.json`, `{sid}.json`으로 주는 계약 + +디자인 방향 (제안): +- 카드 기반의 깔끔한 레이아웃, 둥근 모서리, 넉넉한 여백, 큰 드롭존 +- 노란색은 로고에만. UI 강조색은 중립적인 색(예: 슬레이트/인디고 계열 하나) +- 라이트/다크 모드 (`prefers-color-scheme`), 모바일 우선 반응형 +- 한글 가독성을 위한 시스템 폰트 스택 (`Pretendard` 등 웹폰트는 오프라인 여부 확인 후 결정) +- 접근성: 키보드 조작, 포커스 표시, 충분한 대비 +- Admin 페이지도 같은 스택과 스타일로 통일 + +진행 순서: +1. 화면 흐름(업로드/다운로드 순서) 확정 — 다음 항목들에서 논의 +2. 흐름이 정해지면 업로드 화면 목업을 HTML로 보여주고 디자인 확인 +3. 확정 후 구현 + +결정 (사용자 답변 반영): +- 서버는 외부 인터넷 제한이 없지만 nginx에서 외부 접근을 막을 예정 → 사내망 사용. 외부 CDN/웹폰트에 의존하지 않음. 폰트는 시스템 폰트 스택 + (`Pretendard`가 설치돼 있으면 사용, 없으면 맑은 고딕/Segoe UI 등) +- 강조색은 사용자 선호 없음 → 인디고 계열(`#3552d6`, 다크 `#4d69ee`)로 시작하고 목업을 보고 사용자가 수정. 노란색은 로고에만 +- 목업: `mockup/index.html` (업로드 4상태, 다운로드 5상태, 관리자 파일 관리/설정, 라이트/다크, 모바일). 사용자 검토 후 수정 + +## 3. 새 업로드 버튼 확인 팝업 — 확정 + +- 업로드 진행 중(`uploading`)일 때만 확인 팝업, 그 외 상태(`new`, `uploaded`, `uploadError`)는 확인 없이 바로 새 세션 +- 새 세션은 지금처럼 `document.location.reload()` 유지 (항목 1의 토큰 덕분에 로그인 화면 없음) +- 새 언어 키 `cancelUploadConfirm` 추가 (`en.js`, `ko.js`만. 나머지 언어는 영어 기본값 병합으로 대체) + - ko: `업로드가 진행 중입니다. 새로 시작하면 진행 중인 업로드가 중단됩니다. 계속할까요?` + - en: `An upload is in progress. Starting a new one will cancel it. Continue?` +- 기존 `createNewUploadSession` 키는 삭제하지 않고 둠 + +--- + +## 최종 단계: 번역 정리 (잊지 말 것) + +다른 항목이 모두 확정된 뒤, **최종 화면과 문구에 맞춰** `lang/ko.js`(필요 시 `en.js`)의 오타와 의미가 불분명한 번역을 일괄 정리한다. +화면이 바뀌면 문구도 바뀌므로 마지막에 한 번에 한다. + +알려진 문제 (현재 시점): + +| 키 | 현재 | 문제 | +|---|---|---| +| `login` | 로드인 | 오타 (로그인) | +| `generateRandomPassword` | 렌덤 비밀번호를 생성하기 | 오타, 어색한 어미 | +| `downloadLink` | 링크 다운로드하기 | 뜻이 뒤집힘 (다운로드 링크) | +| `decrypt` | 해독 | 비밀번호 확인 버튼으로 부적절 | +| `retention` | 보유 | 보관 기간이 더 자연스러움 | +| `mailsSent` | 이메일(들)은 서공적으로 보낼. | 오타, 비문 | +| `bucketSizeExceed` | 업로드 크기 %% 은/는 최대의 %% 초과합니다 | 비문 | +| `fileSizeExceed` | 파일 크기 %% 은/는 최대의 %% 초과합니다 | 비문 | +| `createNewUploadSession` | 새로운 업로드 세션 창조하기? | 번역투 (항목 3에서 새 키로 대체) | + +작업 방식: 위 표 외에도 `ko.js` 전체와, 항목 1~3 및 이후 항목에서 새로 추가된 키를 전수 점검한다. +메일 관련 키(`mail*`)는 실제로 쓰이는지 확인한 뒤, 안 쓰이면 정리 대상에서 제외한다. diff --git a/docs/brand/ajin-logo.png b/docs/brand/ajin-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1fcf7a4766c0d7950729064f0e2d9775bbed1bb6 GIT binary patch literal 12523 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 BC<Xuk literal 0 HcmV?d00001 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..3222e50 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,149 @@ +# 설정 + +설정값의 기본값은 [config.js](../config.js)에 있습니다. 이 파일은 직접 고치지 말고 아래 방법을 쓰세요. +**우선순위:** 환경변수 > `config.<NODE_ENV>.js` > `config.js` + +## 환경변수 (도커에서는 이 방법) + +모든 설정은 `PSITRANSFER_` 접두사와 대문자 스네이크 표기로 바꿀 수 있습니다. +(`defaultRetention` → `PSITRANSFER_DEFAULT_RETENTION`) + +```bash +export PSITRANSFER_PORT=8080 +export PSITRANSFER_RETENTIONS='{"one-time":"1회 다운로드","86400":"1일"}' +export PSITRANSFER_QUICK_RETENTIONS=one-time,86400 +node app.js +``` + +값의 형식은 기본값의 형식을 따릅니다. 숫자는 정수로, 목록(`quickRetentions`)은 쉼표로 구분하고, +`retentions`는 JSON으로 적고, `requireBucketPassword`와 `disableQrCode`는 `true`/`false`로 적습니다. + +## 설정 파일 + +`config.<NODE_ENV>.js`가 있으면 `config.js` 위에 덮어씁니다. `NODE_ENV=dev node app.js`는 [config.dev.js](../config.dev.js)를 +사용합니다. 직접 만든 이름(`config.custom.js`)도 `NODE_ENV=custom node app.js`로 쓸 수 있습니다. + +## 설정 목록 + +### 서버와 경로 + +| 설정 | 기본값 | 설명 | +|---|---|---| +| `uploadDir` | `./data` | 파일과 관리자 설정을 저장하는 폴더. 도커에서는 `/data` | +| `settingsDir` | `<uploadDir>/.settings` | 관리자 설정(이름, 로고)을 저장하는 폴더 | +| `baseUrl` | `/` | 하위 경로로 서비스할 때 (`/sharing`) | +| `uploadAppPath` | `/` | 업로드 화면의 주소 (`baseUrl` 아래) | +| `iface`, `port` | `0.0.0.0`, `3000` | 수신 주소와 HTTP 포트 (`port`를 `false`로 두면 HTTP 끔) | +| `sslPort`, `sslKeyFile`, `sslCertFile` | | 직접 HTTPS를 쓸 때. 세 값을 모두 지정해야 켜집니다 | +| `forceHttps` | | HTTPS로 강제 이동. `true` 또는 이동할 주소 | +| `trustProxy` | | 리버스 프록시 뒤에서 실제 IP를 로그에 남길 때 ([Express 문서](https://expressjs.com/en/guide/behind-proxies.html)) | +| `accessLog` | `:date[iso] ...` | [morgan](https://github.com/expressjs/morgan) 형식. `false`면 로그 끔 | + +### 업로드와 보관 + +| 설정 | 기본값 | 설명 | +|---|---|---| +| `retentions` | 1회, 1시간 ~ 8주 | 선택할 수 있는 보관 기간. `초: 이름` (`one-time`은 1회 다운로드). 화면에 보이는 이름은 언어 파일의 `retentions`가 우선합니다 | +| `quickRetentions` | `one-time,86400,259200,432000,604800` | 버튼으로 보이는 보관 기간. 나머지는 "그 외" 목록에 들어갑니다 | +| `defaultRetention` | `604800` | 처음 선택돼 있는 보관 기간 | +| `requireBucketPassword` | `true` | 업로드에 비밀번호를 필수로 합니다. **1회 다운로드는 예외**입니다. 서버에서도 검사합니다 | +| `maxAge` | 75일 | 어떤 파일도 이 시간(초)이 지나면 삭제됩니다. 다운로드되지 않은 1회 다운로드 파일에도 적용됩니다 | +| `maxFileSize`, `maxBucketSize` | 제한 없음 | 파일 하나, 한 번에 올리는 전체의 최대 크기(바이트) | +| `maxPreviewSize` | 2MB | 텍스트 미리보기에서 읽는 최대 크기 | +| `mailTemplate` | `mailto:?subject=...` | 이메일 버튼의 `mailto:` 주소. `%%URL%%`이 다운로드 링크로 바뀝니다 | +| `disableQrCode` | `false` | QR 코드 버튼 숨기기 | +| `defaultLanguage` | `ko` | 브라우저 언어를 알 수 없을 때 쓰는 언어 | + +### 로그인과 관리자 + +| 설정 | 기본값 | 설명 | +|---|---|---| +| `uploadPass` | 없음 | 업로드 비밀번호. 없으면 누구나 올릴 수 있습니다 | +| `adminPass` | 없음 | 관리자 비밀번호. 없으면 `/admin` 페이지가 없습니다 | +| `tokenTtl` | 7일 | 로그인 유지 시간(초). 사용할 때마다 새로 7일이 됩니다 | +| `tokenSecret` | | 로그인 토큰의 서명 키. 비워 두면 두 비밀번호에서 만들어집니다 | + +### 이름과 로고 (기본값) + +| 설정 | 기본값 | 설명 | +|---|---|---| +| `appName` | `Dodo Transfer` | 서비스 이름 | +| `appOwner` | `아진산업(주) 전장선행개발팀` | 하단 저작권 표기의 소유자 | +| `appFromYear` | `2026` | 하단 저작권 표기의 시작 연도 (현재 연도가 더 크면 `2026–2027`처럼 표시) | + +이 값들은 **기본값**입니다. 관리자 페이지의 설정 탭에서 바꾼 값이 있으면 그것이 우선합니다. + +## 로그인 방식 + +업로드 비밀번호를 설정하면 처음 접속할 때 로그인 화면이 나옵니다. + +* 로그인하면 서버가 서명된 토큰을 발급하고, 브라우저가 이를 보관합니다. 비밀번호 자체는 저장하지 않습니다. +* 토큰은 페이지에 접속할 때마다 갱신되어 7일(`tokenTtl`) 동안 유지됩니다. +* 서버에는 로그인 상태가 저장되지 않습니다. 그래서 컨테이너를 다시 시작하거나 다시 만들어도 로그인이 유지됩니다. +* `uploadPass`나 `adminPass`를 바꾸면 이전에 발급된 토큰이 모두 무효가 됩니다. 비밀번호를 바꿔도 로그인을 + 유지하려면 `tokenSecret`을 지정하세요. +* 스크립트(curl)를 위해 `x-passwd: <업로드 비밀번호>` 헤더도 계속 받습니다. + +## 비밀번호 규칙 + +* 보관 기간이 1회 다운로드가 아니면 비밀번호가 필수입니다 (`requireBucketPassword`). +* 업로드할 때 **"인증된 사용자는 비밀번호 없이 다운로드"** 를 켜 두면(기본), 업로드 로그인 상태인 브라우저는 + 다운로드 링크를 열 때 비밀번호를 묻지 않습니다. 로그인하지 않은 사람(외부 수신자)은 비밀번호를 입력해야 합니다. + 업로드 비밀번호가 없는 환경에서는 이 선택지가 나타나지 않습니다. + +## 관리자 페이지 + +`adminPass`를 설정하면 `<BASE_URL>/admin`에서 다음을 할 수 있습니다. + +* **파일 관리:** 올라온 파일 조회, 검색, 정렬, 미리보기, 다운로드(파일, zip, tar.gz), 삭제(파일, bucket 단위). + 관리자 다운로드는 1회 다운로드 파일을 소모하지 않고 다운로드 횟수에도 집계되지 않습니다. + 파일을 삭제하면 받는 사람 화면에서도 바로 사라집니다. +* **설정:** 서비스 이름, 로고(PNG, SVG, WebP, 1MB 이하), 하단 저작권 표기. 저장하면 모든 화면에 바로 적용됩니다. + +설정은 `settingsDir`(기본 `/data/.settings`)에 저장됩니다. 로고를 처음 상태로 되돌리려면 설정 탭의 +"기본 로고로 되돌리기"를 누르세요. 아진산업 로고 파일은 [docs/brand/ajin-logo.png](brand/ajin-logo.png)에 있습니다. + +## SSL + +nginx에서 SSL을 처리하는 것을 권장합니다. [nginx-ssl-example.conf](nginx-ssl-example.conf)를 참고하세요. +직접 처리하려면 `sslPort`, `sslKeyFile`, `sslCertFile`을 모두 지정합니다. 테스트용 인증서는 +`openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.pem`으로 만듭니다. + +## 웹훅 + +다른 시스템과 연동하려면 이벤트가 생길 때 POST 요청을 받을 주소를 지정합니다. + +### fileUploadedWebhook + +파일 하나의 업로드가 끝날 때마다 호출됩니다. 비밀번호는 보내지 않고 `_password: true`로 표시합니다. + +```json +{ + "metadata": { + "sid": "6055ab792b6c", + "retention": "3600", + "_password": true, + "name": "test.png", + "comment": "파일 코멘트", + "type": "image/png", + "key": "135a3814-df46-4e23-b061-03bdda13425c", + "createdAt": "1589276618052" + }, + "date": 1589276619385 +} +``` + +여러 파일을 한 번에 올리면 파일마다 호출됩니다 (`sid`는 같고 `key`가 다릅니다). +파일 주소는 `https://<호스트>/<BASE_URL>files/<sid>++<key>`입니다. + +### fileDownloadedWebhook + +받는 사람이 파일(또는 전체 받기)을 다운로드할 때 호출됩니다. 미리보기와 관리자 다운로드는 호출하지 않습니다. + +```json +{ + "fid": "6055ab792b6c", + "file": "test.png", + "date": 1589276619415 +} +``` diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md new file mode 100644 index 0000000..0207fdb --- /dev/null +++ b/docs/deployment-docker.md @@ -0,0 +1,66 @@ +# Docker로 배포하기 + +도커 사용을 권장합니다. 이미지는 이 저장소의 `Dockerfile`로 빌드하며, 프론트엔드 빌드도 이미지 빌드 안에서 +함께 이루어집니다. 서버에 Node.js를 따로 설치할 필요가 없습니다. + +## 빠른 시작 + +```bash +git clone https://git.dodo-lab.kr/blue2rai/DodoTransfer.git +cd DodoTransfer +cp .env.example .env # 두 비밀번호를 정해서 .env에 적습니다 +docker compose -f compose.example.yaml up -d --build +``` + +[compose.example.yaml](../compose.example.yaml)은 이 프로젝트의 운영 예시입니다. +서버 환경에 맞게 복사해서 고쳐 쓰세요. 중요한 부분은 다음과 같습니다. + +| 항목 | 설명 | +|---|---| +| `user: "1000:1000"` | 데이터 폴더 소유자의 uid/gid와 같아야 합니다. | +| `ports: "127.0.0.1:3377:3000"` | 외부에 직접 열지 않고 같은 서버의 nginx만 접근하게 합니다. | +| `env_file: .env` | 비밀번호는 compose 파일이 아니라 `.env`에 둡니다 (`.env`는 git에 올라가지 않습니다). | +| `volumes: /data` | 업로드한 파일과 관리자 설정(이름, 로고)이 모두 여기에 저장됩니다. 컨테이너를 다시 만들어도 유지됩니다. | + +## 하위 경로(`/sharing`)로 서비스하기 + +`PSITRANSFER_BASE_URL: "/sharing"`으로 설정하면 모든 주소가 `/sharing/` 아래로 옮겨갑니다. +nginx는 경로를 그대로 넘기면 됩니다 (경로를 잘라내지 않습니다). + +```nginx +location /sharing/ { + proxy_pass http://127.0.0.1:3377; + proxy_http_version 1.1; + proxy_buffering off; + proxy_request_buffering off; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 10m; # 파일은 5MB 조각으로 올라갑니다 +} +``` + +## 상태 확인 + +이미지에는 `HEALTHCHECK`가 들어 있습니다. 로그인이 필요 없는 `<BASE_URL>/healthz`가 `ok`로 답하면 +정상입니다. + +```bash +docker compose -f compose.example.yaml ps # STATUS가 healthy +curl http://127.0.0.1:3377/sharing/healthz # ok +``` + +## 업데이트 + +```bash +git pull +docker compose -f compose.example.yaml up -d --build +``` + +데이터(`/data`)는 그대로 유지됩니다. 이전 PsiTransfer가 저장한 파일과 비밀번호도 그대로 사용할 수 있습니다. +(이전에 올린 파일에는 썸네일이 없어서 아이콘으로 표시됩니다.) + +## 환경변수 + +모든 설정은 `PSITRANSFER_` 접두사의 환경변수로 바꿀 수 있습니다. 목록은 [configuration.md](configuration.md)를 +참고하세요. diff --git a/docs/deployment-systemd.md b/docs/deployment-systemd.md new file mode 100644 index 0000000..f9f1d61 --- /dev/null +++ b/docs/deployment-systemd.md @@ -0,0 +1,36 @@ +# systemd 서비스로 배포하기 + +도커 대신 리눅스 서비스로 설치할 수도 있습니다. **root 권한으로 실행하지 마세요.** +Node.js 24 이상이 필요합니다. + +## 준비 + +```bash +sudo mkdir -p /opt/dodo-transfer +sudo useradd --system dodo-transfer + +git clone https://git.dodo-lab.kr/blue2rai/DodoTransfer.git /opt/dodo-transfer +cd /opt/dodo-transfer + +# 프론트엔드 빌드 (public/app에 만들어집니다) +npm --prefix app ci +npm run build + +# 서버 의존성 +npm ci --omit=dev + +sudo chown -R dodo-transfer:dodo-transfer /opt/dodo-transfer +``` + +## 서비스 등록 + +[dodo-transfer.service](dodo-transfer.service)를 `/etc/systemd/system/`에 복사하고 환경에 맞게 고칩니다. +비밀번호는 서비스 파일에 직접 쓰지 말고 `EnvironmentFile`로 읽는 것을 권장합니다 +(예: `/etc/dodo-transfer.env`, 권한 `600`, 내용은 [.env.example](../.env.example) 참고). + +```bash +sudo cp docs/dodo-transfer.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now dodo-transfer # 시작하고 부팅 시 자동 시작 +sudo systemctl status dodo-transfer +``` diff --git a/docs/dodo-transfer.service b/docs/dodo-transfer.service new file mode 100644 index 0000000..81a7a80 --- /dev/null +++ b/docs/dodo-transfer.service @@ -0,0 +1,22 @@ +[Unit] +Description=Dodo Transfer +Documentation=https://git.dodo-lab.kr/blue2rai/DodoTransfer +After=network.target + +[Service] +Type=simple +User=dodo-transfer +Restart=on-failure + +Environment=NODE_ENV=production +Environment=PSITRANSFER_PORT=8080 +Environment=PSITRANSFER_UPLOAD_DIR=/var/lib/dodo-transfer +# passwords: PSITRANSFER_ADMIN_PASS, PSITRANSFER_UPLOAD_PASS +EnvironmentFile=-/etc/dodo-transfer.env + +StateDirectory=dodo-transfer +WorkingDirectory=/opt/dodo-transfer +ExecStart=/usr/bin/node app.js + +[Install] +WantedBy=multi-user.target diff --git a/docs/layout-customization.md b/docs/layout-customization.md new file mode 100644 index 0000000..efba26c --- /dev/null +++ b/docs/layout-customization.md @@ -0,0 +1,36 @@ +# 화면 바꾸기 + +| 바꾸려는 것 | 방법 | 빌드 | +|---|---|---| +| 서비스 이름, 로고, 하단 저작권 표기 | 관리자 페이지 > 설정 | 필요 없음 | +| 이름의 기본값 | 환경변수 `PSITRANSFER_APP_NAME`, `PSITRANSFER_APP_OWNER`, `PSITRANSFER_APP_FROM_YEAR` | 필요 없음 | +| 화면의 문구 | `lang/ko.js` (한국어), `lang/en.js` (기본) | 필요 없음, 서버 재시작 | +| 색, 간격, 글꼴 | `app/src/styles.css` 맨 위의 CSS 변수 (`--accent` 등) | `npm run build` | +| 화면 구성 | `app/src/pages/*.vue`, `app/src/components/*.vue` | `npm run build` | +| 모든 페이지의 `<head>`에 넣을 것 (스타일, 스크립트) | `public/pug/partials/head_custom.pug` | 필요 없음, 서버 재시작 | + +## 구조 + +서버는 얇은 HTML 껍데기([public/pug/page.pug](../public/pug/page.pug))만 만들고, 실제 화면은 브라우저에서 +Vue 3 앱(`app/`)이 그립니다. 업로드, 다운로드, 관리자 세 화면이 하나의 스크립트(`public/app/main.js`)로 +동작하고, 어느 화면인지는 껍데기의 `<body data-page="...">`가 알려 줍니다. + +```bash +npm --prefix app ci # 처음 한 번 +npm run build # app/src를 public/app으로 빌드 +``` + +도커 이미지는 빌드 과정에서 이 빌드를 함께 하므로 따로 할 필요가 없습니다. + +## 색 바꾸기 + +`app/src/styles.css`의 맨 위에 라이트 모드와 다크 모드의 색이 변수로 모여 있습니다. +강조색은 `--accent`(버튼, 선택된 항목)와 `--accent-soft`/`--accent-ink`(연한 배경과 그 위의 글자)입니다. +로고에는 영향을 주지 않습니다. + +## 로고 파일 + +기본 로고는 `public/assets/logo.svg`(임의로 만든 아이콘)입니다. 관리자 설정에서 올린 로고가 있으면 그것이 우선하고, +설정 폴더(`/data/.settings`)에 저장됩니다. 코드를 바꾸지 않고 로고를 교체하려면 관리자 페이지를 쓰세요. + +> 업데이트할 때 충돌을 줄이려면 `app/src`를 크게 바꾸는 것보다 관리자 설정, 환경변수, `head_custom.pug`를 먼저 활용하세요. diff --git a/docs/mockup/index.html b/docs/mockup/index.html new file mode 100644 index 0000000..fdeabdc --- /dev/null +++ b/docs/mockup/index.html @@ -0,0 +1,893 @@ +<!doctype html> +<html lang="ko"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>AJIN Transfer 목업 + + + + + +
+ 목업 +
화면 +
+ +
+
+
상태 +
+ +
+
+ +
테마 +
+ +
+
+ 창 너비를 줄이면 모바일 배치를 볼 수 있습니다 · 비밀번호 화면 예시 입력: ajin +
+ + +
+
+
AJIN Transfer
+
+ + +
+ +
+ +
+
+ +
+ + +
+ + +
+ + + + + + + + +
+
+
+
파일을 여기로 끌어다 놓거나 클릭해서 추가
+
+ +
+
+
+ + +
+
+
AJIN Transfer
+
+ +
+
+
+
+ + +
+
+
AJIN Transfer관리자
+
+ +
+
+
+
+ + + + + + + + + + + +
+ + + + diff --git a/docs/mockup/logo.png b/docs/mockup/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1fcf7a4766c0d7950729064f0e2d9775bbed1bb6 GIT binary patch literal 12523 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 1.7.11 + proxy_set_header Host $http_host; + } +} diff --git a/lang/en.js b/lang/en.js new file mode 100644 index 0000000..91a8417 --- /dev/null +++ b/lang/en.js @@ -0,0 +1,171 @@ +// English is the base language, every other language file only overrides keys. +// Placeholders {0}, {1} are replaced by the app. +module.exports = { + langCode: "en", + + // general + noScript: "This page requires JavaScript.", + loading: "Loading…", + errorTitle: "The request could not be processed", + newUpload: "New upload", + myUploads: "My uploads", + close: "Close", + cancel: "Cancel", + delete: "Delete", + download: "Download", + preview: "Preview", + previous: "Previous", + next: "Next", + refresh: "Refresh", + files: "Files", + filesCount: "{0} files", + none: "None", + copyLink: "Copy link", + linkCopied: "The link was copied", + linkAndPasswordCopied: "The link and the password were copied", + password: "Password", + accessDenied: "Access denied", + noPreviewAvailable: "No preview available", + cannotPlay: "This browser can not play this format. Download the file to open it.", + oneTimeDownload: "Deleted after one download", + expiresLabel: "Expires {0}", + remainMinutes: "{0} min left", + remainHours: "{0} h left", + remainDays: "{0} days left", + fileSizeExceed: "File size {0} exceeds the maximum of {1}", + bucketSizeExceed: "Upload size {0} exceeds the maximum of {1}", + + // login + uploadPassword: "Password for uploading", + loginHint: "After logging in you stay logged in on this browser for a while.", + login: "Log in", + + // upload + addFiles: "Add files", + addFilesHint: "You can also drop files anywhere on the page or paste them with Ctrl+V", + dropHere: "Drop your files here", + dropOrClick: "or click to select files", + dropStrip: "Drop files here or click to add", + dropOverlayTitle: "Drop to add files", + dropOverlayHint: "The files are added to the list", + filesAdded: "{0} files added", + noAddWhileUploading: "Files can not be added while uploading", + pastedFile: "pasted", + retention: "Retention", + retentionMore: "More", + passwordRequired: "Password (required)", + passwordOptional: "Password (optional)", + enterPassword: "Please enter a password", + generatePassword: "Generate a random password", + clearPassword: "Clear password", + bypassLabel: "Logged-in users can download without a password", + passwordSet: "password set", + passwordNeeded: "password needed", + passwordNone: "no password", + upload: "Upload", + retry: "Retry", + comment: "Comment (optional)", + removeFile: "Remove file", + done: "Done", + cancelUploadConfirm: "An upload is in progress. Starting a new one will cancel it. Continue?", + offline: "You are offline. The uploads resume as soon as you are back online.", + uploadFailed: "The upload failed.", + uploadCompleted: "Upload completed", + uploadCompletedHint: "Recipients can download the files with this link until it expires.", + copyLinkAndPassword: "Copy link and password", + shareTitle: "{0} file share", + shareLink: "Download link", + sharePassword: "Password", + shareExpires: "Expires", + shareBypass: "Password waived for", + bypassUsers: "Logged-in users", + qrCode: "QR code", + qrHint: "Scan the code with a phone to open the download page", + email: "E-Mail", + + // my uploads + myUploadsEmpty: "There are no uploads from this browser yet.", + myUploadsNote: "Only uploads made with this browser are listed. They are not visible on other computers and disappear when the browser data is cleared. Passwords are not stored.", + statusExpired: "Expired", + statusGone: "Deleted or expired", + openLink: "Open", + removeFromList: "Remove from list", + + // download + sharedFiles: "{0} shared files", + downloadAll: "Download all", + needPassword: "A password is required", + needPasswordHint: "Enter the password you received from the sender.", + confirm: "Confirm", + wrongPassword: "The password is incorrect", + bypassNotice: "Your login was recognized, no password is needed.", + passwordProtected: "Password protected", + linkNotFound: "Link not found", + linkNotFoundHint: "The link has expired or was deleted. Ask the sender for a new one.", + fileGone: "The file was deleted or has expired.", + oneTimeUsed: "This one-time file was already downloaded.", + previewOf: "Preview of {0}", + + // admin + adminTitle: "Admin", + adminFiles: "File management", + adminSettings: "Settings", + totalUsage: "Total usage", + bucketCount: "Buckets", + fileCount: "Files", + expiringSoon: "Expiring within 24 h", + searchPlaceholder: "Search SID or file name", + sortNew: "Newest first", + sortSize: "Largest first", + sortExpire: "Expiring first", + colSid: "SID", + colFiles: "Files", + colSize: "Size", + colCreated: "Created", + colLastDownload: "Last download", + colExpires: "Expires", + noResults: "No results", + unfinished: "unfinished", + deleteBucket: "Delete bucket", + deleteBucketTitle: "Delete the bucket?", + deleteBucketBody: "{0} files ({1}) of {2} are deleted from the server immediately.", + deleteFileTitle: "Delete the file?", + deleteVisibleNote: "The files disappear for the recipients as well. The remaining files of the same link can still be downloaded.", + deleteIrreversible: "Deleted files can not be restored.", + deleted: "Deleted", + adminDownloadNote: "Admin downloads do not use up one-time files", + refreshed: "Refreshed", + + // admin settings + brandSection: "Brand", + serviceName: "Service name", + serviceNameHelp: "Shown next to the logo, in the browser tab title and in the footer.", + logo: "Logo", + chooseImage: "Choose image", + resetLogo: "Restore the default logo", + logoHelp: "PNG, SVG or WebP, at most 1 MB. A square image is recommended. It is used as the browser tab icon as well.", + logoTooLarge: "Only images up to 1 MB can be used", + footerSection: "Copyright footer", + ownerLabel: "Owner", + fromYear: "Start year", + footerHelp: "When the current year is later than the start year a range is shown automatically.", + save: "Save", + revert: "Revert", + saved: "Saved. The change is visible everywhere right away.", + reverted: "Reverted to the saved settings", + nameRequired: "Please enter a service name", + settingsStoredNote: "The settings are stored in the data folder (/data) and survive re-creating the container.", + + retentions: { + "one-time": "One-time download", + "3600": "1 hour", + "21600": "6 hours", + "86400": "1 day", + "259200": "3 days", + "432000": "5 days", + "604800": "7 days", + "1209600": "2 weeks", + "2419200": "4 weeks", + "4838400": "8 weeks" + }, +} diff --git a/lang/ko.js b/lang/ko.js new file mode 100644 index 0000000..9790334 --- /dev/null +++ b/lang/ko.js @@ -0,0 +1,171 @@ +// 영어(en.js)가 기본이고, 이 파일은 한국어 문구만 덮어씁니다. +// {0}, {1}은 화면에서 값으로 바뀝니다. +module.exports = { + langCode: "ko", + + // 공통 + noScript: "이 페이지는 JavaScript가 필요합니다.", + loading: "불러오는 중…", + errorTitle: "요청을 처리할 수 없습니다", + newUpload: "새 업로드", + myUploads: "내 업로드", + close: "닫기", + cancel: "취소", + delete: "삭제", + download: "다운로드", + preview: "미리보기", + previous: "이전", + next: "다음", + refresh: "새로고침", + files: "파일", + filesCount: "파일 {0}개", + none: "없음", + copyLink: "링크 복사", + linkCopied: "링크를 복사했습니다", + linkAndPasswordCopied: "링크와 비밀번호를 복사했습니다", + password: "비밀번호", + accessDenied: "접근이 거부되었습니다", + noPreviewAvailable: "미리보기를 사용할 수 없습니다", + cannotPlay: "이 브라우저에서 재생할 수 없는 형식입니다. 다운로드해서 확인하세요.", + oneTimeDownload: "1회 다운로드 후 삭제됩니다", + expiresLabel: "{0} 만료", + remainMinutes: "{0}분 남음", + remainHours: "{0}시간 남음", + remainDays: "{0}일 남음", + fileSizeExceed: "파일 크기 {0}이(가) 최대 크기 {1}을(를) 초과했습니다", + bucketSizeExceed: "업로드 크기 {0}이(가) 최대 크기 {1}을(를) 초과했습니다", + + // 로그인 + uploadPassword: "업로드 비밀번호", + loginHint: "로그인하면 이 브라우저에서는 일정 기간 다시 입력하지 않아도 됩니다.", + login: "로그인", + + // 업로드 + addFiles: "파일 추가", + addFilesHint: "파일을 화면 어디에든 끌어다 놓거나 Ctrl+V로 붙여넣을 수도 있어요", + dropHere: "여기에 파일을 끌어다 놓으세요", + dropOrClick: "또는 클릭해서 파일을 선택합니다", + dropStrip: "파일을 여기로 끌어다 놓거나 클릭해서 추가", + dropOverlayTitle: "여기에 놓아 파일 추가", + dropOverlayHint: "놓으면 목록에 추가됩니다", + filesAdded: "파일 {0}개를 추가했습니다", + noAddWhileUploading: "업로드 중에는 파일을 추가할 수 없습니다", + pastedFile: "붙여넣기", + retention: "보관 기간", + retentionMore: "그 외", + passwordRequired: "비밀번호 (필수)", + passwordOptional: "비밀번호 (선택)", + enterPassword: "비밀번호를 입력하세요", + generatePassword: "무작위 비밀번호 생성", + clearPassword: "비밀번호 지우기", + bypassLabel: "인증된 사용자는 비밀번호 없이 다운로드", + passwordSet: "비밀번호 설정됨", + passwordNeeded: "비밀번호 필요", + passwordNone: "비밀번호 없음", + upload: "업로드", + retry: "다시 시도", + comment: "코멘트 (선택)", + removeFile: "파일 제거", + done: "완료", + cancelUploadConfirm: "업로드가 진행 중입니다. 새로 시작하면 진행 중인 업로드가 중단됩니다. 계속할까요?", + offline: "오프라인 상태입니다. 연결이 돌아오면 업로드를 이어서 진행합니다.", + uploadFailed: "업로드에 실패했습니다.", + uploadCompleted: "업로드가 완료되었습니다", + uploadCompletedHint: "받는 사람은 만료 전까지 이 링크로 파일을 내려받을 수 있습니다.", + copyLinkAndPassword: "링크와 비밀번호 복사", + shareTitle: "{0} 파일 공유", + shareLink: "다운로드 링크", + sharePassword: "비밀번호", + shareExpires: "만료", + shareBypass: "비밀번호 면제", + bypassUsers: "인증된 사용자", + qrCode: "QR 코드", + qrHint: "휴대폰 카메라로 스캔하면 다운로드 페이지가 열립니다", + email: "이메일", + + // 내 업로드 + myUploadsEmpty: "이 브라우저에서 올린 업로드가 아직 없습니다.", + myUploadsNote: "이 브라우저에서 올린 항목만 표시됩니다. 다른 PC에서는 보이지 않고, 브라우저 데이터를 지우면 사라집니다. 비밀번호는 저장하지 않습니다.", + statusExpired: "만료됨", + statusGone: "삭제됨 또는 만료됨", + openLink: "열기", + removeFromList: "목록에서 제거", + + // 다운로드 + sharedFiles: "공유된 파일 {0}개", + downloadAll: "전체 받기", + needPassword: "비밀번호가 필요합니다", + needPasswordHint: "보낸 사람에게 받은 비밀번호를 입력하세요.", + confirm: "확인", + wrongPassword: "비밀번호가 올바르지 않습니다", + bypassNotice: "사내 인증이 확인되어 비밀번호 없이 열렸습니다.", + passwordProtected: "비밀번호 보호", + linkNotFound: "링크를 찾을 수 없습니다", + linkNotFoundHint: "만료되었거나 삭제된 링크입니다. 보낸 사람에게 다시 요청하세요.", + fileGone: "삭제되었거나 만료된 파일입니다.", + oneTimeUsed: "1회 다운로드 파일이라 이미 받았습니다.", + previewOf: "{0} 미리보기", + + // 관리자 + adminTitle: "관리자", + adminFiles: "파일 관리", + adminSettings: "설정", + totalUsage: "전체 사용 용량", + bucketCount: "bucket", + fileCount: "파일", + expiringSoon: "24시간 내 만료", + searchPlaceholder: "SID 또는 파일 이름 검색", + sortNew: "최신순", + sortSize: "용량 큰 순", + sortExpire: "만료 임박 순", + colSid: "SID", + colFiles: "파일", + colSize: "크기", + colCreated: "생성", + colLastDownload: "마지막 다운로드", + colExpires: "만료", + noResults: "결과가 없습니다", + unfinished: "미완료", + deleteBucket: "bucket 삭제", + deleteBucketTitle: "bucket을 삭제할까요?", + deleteBucketBody: "{2}의 파일 {0}개({1})가 서버에서 즉시 삭제됩니다.", + deleteFileTitle: "파일을 삭제할까요?", + deleteVisibleNote: "받는 사람 화면에서도 즉시 사라집니다. 같은 링크의 나머지 파일은 그대로 받을 수 있습니다.", + deleteIrreversible: "삭제하면 복구할 수 없습니다.", + deleted: "삭제했습니다", + adminDownloadNote: "관리자 다운로드는 1회 다운로드 파일을 소모하지 않습니다", + refreshed: "새로고침했습니다", + + // 관리자 설정 + brandSection: "브랜드", + serviceName: "서비스 이름", + serviceNameHelp: "로고 옆, 브라우저 탭 제목, 하단 표기에 사용됩니다.", + logo: "로고", + chooseImage: "이미지 선택", + resetLogo: "기본 로고로 되돌리기", + logoHelp: "PNG, SVG, WebP 형식, 최대 1MB. 정사각형을 권장합니다. 브라우저 탭 아이콘으로도 사용됩니다.", + logoTooLarge: "1MB 이하의 이미지만 사용할 수 있습니다", + footerSection: "하단 저작권 표기", + ownerLabel: "소유자 문구", + fromYear: "시작 연도", + footerHelp: "현재 연도가 시작 연도보다 크면 자동으로 범위로 표시됩니다.", + save: "저장", + revert: "되돌리기", + saved: "저장했습니다. 모든 화면에 바로 적용됩니다.", + reverted: "저장된 설정으로 되돌렸습니다", + nameRequired: "서비스 이름을 입력하세요", + settingsStoredNote: "설정은 데이터 폴더(/data)에 저장되어 컨테이너를 다시 만들어도 유지됩니다.", + + retentions: { + "one-time": "1회 다운로드", + "3600": "1시간", + "21600": "6시간", + "86400": "1일", + "259200": "3일", + "432000": "5일", + "604800": "7일", + "1209600": "2주", + "2419200": "4주", + "4838400": "8주" + }, +} diff --git a/lib/admin.js b/lib/admin.js new file mode 100644 index 0000000..0237c01 --- /dev/null +++ b/lib/admin.js @@ -0,0 +1,166 @@ +'use strict'; +const express = require('express'); +const config = require('../config'); +const auth = require('./auth'); +const settings = require('./settings'); +const mime = require('./mime'); +const utils = require('./utils'); +const { pipeArchive } = require('./archive'); + +/** + * Admin area: manage the uploaded files and the settings (name, logo). + * Mounted at admin, only available when adminPass is set. + */ +module.exports = function createAdmin({ db, store, renderPage }) { + const router = express.Router(); + + // not configured: there is no admin area + router.use((req, res, next) => config.adminPass ? next() : next('router')); + + router.get('/', (req, res) => { + res.send(renderPage(req, 'admin')); + }); + + const guard = (req, res, next) => auth.isAdmin(req) ? next() : auth.deny(req, res); + + function fileInfo(sid, file) { + const m = file.metadata; + const oneTime = m.retention === 'one-time'; + return { + key: file.key, + name: m.name, + comment: m.comment || '', + size: file.isPartial ? +m.uploadLength : file.size, + // unfinished uploads, they use disk space as well + partial: !!file.isPartial, + retention: m.retention, + createdAt: +m.createdAt, + lastDownload: m.lastDownload ? +m.lastDownload : null, + hasPassword: !!m.password, + internalBypass: m.internalBypass === 'true', + expiresAt: db.expiresAt(file), + // previews of one-time files would use them up + previewType: oneTime || file.isPartial ? null : mime.previewKind(m.name), + url: `${ config.baseUrl }files/${ sid }++${ file.key }`, + thumbUrl: m.thumb && !oneTime ? `${ config.baseUrl }files/${ sid }++${ file.key }.thumb` : null, + }; + } + + router.get('/data.json', guard, (req, res) => { + const buckets = Object.keys(db.db) + .map(sid => ({ sid, files: db.db[sid].map(file => fileInfo(sid, file)) })) + .filter(bucket => bucket.files.length > 0); + res.set('Cache-Control', 'no-store'); + res.json({ + buckets, + now: Date.now(), + totalSize: buckets.reduce((sum, b) => sum + b.files.reduce((s, f) => s + (f.size || 0), 0), 0), + }); + }); + + // Browsers can not send a header with a plain download link, so the page asks + // for a link which is valid for one minute. + router.post('/download-link', guard, (req, res) => { + const { sid, key, format } = req.body || {}; + if (!utils.isSafeBucketFid(sid)) return res.status(400).json({ message: 'Invalid bucket id' }); + const files = (db.get(sid) || []).filter(file => !file.isPartial); + if (!files.length) return res.status(404).json({ message: 'Bucket not found' }); + if (key) { + if (!files.some(file => file.key === key)) return res.status(404).json({ message: 'File not found' }); + } else if (!['zip', 'tar.gz'].includes(format)) { + return res.status(400).json({ message: 'Invalid format' }); + } + const { token } = auth.issue({ sid, key: key || null, format: key ? null : format }, 60, 'admin-download'); + res.json({ url: `${ config.baseUrl }admin/dl?t=${ encodeURIComponent(token) }` }); + }); + + // Downloads of the admin neither consume one-time files nor count as download + router.get('/dl', (req, res) => { + const data = auth.verify(String(req.query.t || ''), 'admin-download'); + if (!data) return res.status(403).send('Link expired'); + const files = (db.get(data.sid) || []).filter(file => !file.isPartial); + res.set('Cache-Control', 'no-store, no-transform'); + + if (data.key) { + const file = files.find(f => f.key === data.key); + if (!file) return res.status(404).send('File not found'); + res.set('Content-Disposition', utils.contentDisposition(file.metadata.name, file.key)); + return res.sendFile(store.getFilename(`${ data.sid }++${ file.key }`), err => { + if (err && !res.headersSent) res.status(404).end(); + }); + } + + if (!files.length) return res.status(404).send('Bucket not found'); + res.set('Content-Disposition', utils.contentDisposition(`${ data.sid }.${ data.format }`, 'files')); + return pipeArchive(res, files, data.format, store); + }); + + router.delete('/buckets/:sid', guard, async (req, res, next) => { + try { + if (!utils.isSafeBucketFid(req.params.sid)) return res.status(400).json({ message: 'Invalid bucket id' }); + const removed = await db.removeBucket(req.params.sid); + if (!removed) return res.status(404).json({ message: 'Bucket not found' }); + res.json({ removed }); + } catch (e) { + next(e); + } + }); + + router.delete('/buckets/:sid/files/:key', guard, async (req, res, next) => { + try { + const { sid, key } = req.params; + if (!utils.isSafeBucketFid(sid) || !utils.UUID_V4_RE.test(key)) { + return res.status(400).json({ message: 'Invalid id' }); + } + if (!(db.get(sid) || []).some(file => file.key === key)) { + return res.status(404).json({ message: 'File not found' }); + } + await db.remove(sid, key); + res.json({ removed: 1 }); + } catch (e) { + next(e); + } + }); + + // Settings: name, logo, footer + router.get('/settings', guard, (req, res) => { + res.set('Cache-Control', 'no-store'); + res.json({ brand: settings.brand(), defaults: settings.defaults(), maxLogoSize: settings.MAX_LOGO_SIZE }); + }); + + router.put('/settings', guard, (req, res, next) => { + try { + res.json({ brand: settings.update(req.body || {}) }); + } catch (e) { + next(e); + } + }); + + router.post('/settings/logo', guard, + express.raw({ type: () => true, limit: settings.MAX_LOGO_SIZE }), + (req, res, next) => { + try { + res.json({ brand: settings.setLogo(req.body) }); + } catch (e) { + next(e); + } + } + ); + + router.delete('/settings/logo', guard, (req, res, next) => { + try { + res.json({ brand: settings.clearLogo() }); + } catch (e) { + next(e); + } + }); + + router.use((err, req, res, next) => { + if (res.headersSent) return next(err); + const status = err.status || err.statusCode || 500; + if (status >= 500) console.error(err); + res.status(status).json({ message: status >= 500 ? 'Internal error' : err.message }); + }); + + return router; +}; diff --git a/lib/archive.js b/lib/archive.js new file mode 100644 index 0000000..8f8ab73 --- /dev/null +++ b/lib/archive.js @@ -0,0 +1,80 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { createGzip } = require('zlib'); +const tar = require('tar-stream'); +const utils = require('./utils'); + +/** Flat archives need unique names: "a.txt", "a (2).txt", ... */ +function uniqueNamer() { + const used = new Map(); + return (rawName, fallback) => { + const base = utils.toSafeBasename(rawName, fallback); + const prev = used.get(base) || 0; + used.set(base, prev + 1); + if (prev === 0) return base; + const ext = path.extname(base); + const stem = ext ? base.slice(0, -ext.length) : base; + return `${ stem } (${ prev + 1 })${ ext }`; + }; +} + +const fileOf = (store, info) => store.getFilename(info.metadata.sid + '++' + info.key); + +async function pipeZip(res, bucket, store) { + res.header('Content-Type', 'application/zip'); + const { ZipArchive } = await import('archiver'); + const archive = new ZipArchive(); + archive.on('error', err => { + console.error(err); + res.destroy(err); + }); + archive.pipe(res); + + const name = uniqueNamer(); + for (const info of bucket) { + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(fileOf(store, info)); + stream.on('end', resolve); + stream.on('error', reject); + archive.append(stream, { name: name(info.metadata.name, info.key) }); + }); + } + await archive.finalize(); +} + +async function pipeTarGz(res, bucket, store) { + res.header('Content-Type', 'application/gzip'); + const pack = tar.pack(); + pack.on('error', err => res.destroy(err)); + pack.pipe(createGzip()).pipe(res); + + const name = uniqueNamer(); + for (const info of bucket) { + await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(fileOf(store, info)); + const entry = pack.entry({ name: name(info.metadata.name, info.key), size: info.size }); + readStream.on('error', reject); + entry.on('error', reject); + entry.on('finish', resolve); + readStream.pipe(entry); + }); + } + pack.finalize(); +} + +/** + * Stream all files of a bucket as zip or tar.gz. Errors while reading a file + * abort the response, so a broken archive is never mistaken for a complete one. + */ +async function pipeArchive(res, bucket, format, store) { + try { + if (format === 'zip') await pipeZip(res, bucket, store); + else await pipeTarGz(res, bucket, store); + } catch (err) { + console.error(err); + res.destroy(err); + } +} + +module.exports = { pipeArchive, uniqueNamer }; diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..1324c5d --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,93 @@ +'use strict'; +const { createHmac, createHash, timingSafeEqual } = require('node:crypto'); +const config = require('../config'); + +/** + * Compare two secrets in constant time (hashing first hides the length). + */ +function safeEqual(a, b) { + const ha = createHash('sha256').update(String(a ?? '')).digest(); + const hb = createHash('sha256').update(String(b ?? '')).digest(); + return timingSafeEqual(ha, hb); +} + +/** + * Signing key. Stateless on purpose: it only depends on the configuration, so + * tokens survive container restarts and are invalidated when a password changes. + */ +function secret() { + if (config.tokenSecret) return String(config.tokenSecret); + return createHash('sha256') + .update(`psitransfer-token-v1\0${ config.uploadPass || '' }\0${ config.adminPass || '' }`) + .digest('hex'); +} + +function sign(payload, purpose) { + return createHmac('sha256', `${ secret() }:${ purpose }`).update(payload).digest('base64url'); +} + +/** + * @param {object} data extra payload + * @param {number} ttlSec lifetime in seconds + * @param {string} purpose tokens of one purpose are not valid for another + */ +function issue(data, ttlSec, purpose = 'session', now = Date.now()) { + const exp = now + ttlSec * 1000; + const payload = Buffer.from(JSON.stringify({ ...data, exp })).toString('base64url'); + return { token: `${ payload }.${ sign(payload, purpose) }`, exp }; +} + +function verify(token, purpose = 'session', now = Date.now()) { + if (typeof token !== 'string') return null; + const dot = token.indexOf('.'); + if (dot < 1) return null; + const payload = token.slice(0, dot); + const given = Buffer.from(token.slice(dot + 1)); + const expected = Buffer.from(sign(payload, purpose)); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null; + let data; + try { + data = JSON.parse(Buffer.from(payload, 'base64url').toString()); + } catch (e) { + return null; + } + if (!data || typeof data.exp !== 'number' || data.exp <= now) return null; + return data; +} + +function bearer(req) { + const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || ''); + return m ? m[1] : null; +} + +/** Does the request carry a valid (not expired) login token? */ +function hasSession(req) { + const token = bearer(req); + return !!token && !!verify(token); +} + +/** + * Upload authentication. + * @returns {'open'|'token'|'password'|null} null when the request is not allowed + */ +function uploadAuth(req) { + if (!config.uploadPass) return 'open'; + if (hasSession(req)) return 'token'; + // legacy: raw password header, keeps scripts (curl) working + const pass = req.get('x-passwd'); + if (pass && safeEqual(pass, config.uploadPass)) return 'password'; + return null; +} + +function isAdmin(req) { + return !!config.adminPass && safeEqual(req.get('x-passwd'), config.adminPass); +} + +/** Answer a rejected request slowly to make brute force attacks harder. */ +function deny(req, res, delay = 500) { + const given = req.get('authorization') || req.get('x-passwd'); + const status = given ? 403 : 401; + setTimeout(() => res.status(status).send(status === 401 ? 'Unauthorized' : 'Forbidden'), delay); +} + +module.exports = { safeEqual, issue, verify, hasSession, uploadAuth, isAdmin, deny }; diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..b5faa69 --- /dev/null +++ b/lib/db.js @@ -0,0 +1,216 @@ +'use strict'; +const fsp = require('fs-promise'); +const path = require('path'); +const debug = require('debug')('psitransfer:db'); +const config = require('../config'); + +module.exports = class DB { + + constructor(uploadDir, store) { + this.initialized = false; + this.db = {}; + // fids of one-time files with a running download + this.claims = new Set(); + + this.uploadDir = uploadDir; + this.store = store; + + // delete expired files + const gc = () => { + const now = Date.now(); + for (const sid of Object.keys(this.db)) { + for (const f of this.db[sid].slice()) { + if(this.expiresAt(f) <= now) { + debug(`Expired ${sid}++${f.key}`); + this.remove(sid, f.key).catch(e => console.error(e)); + } + } + } + }; + setInterval(gc, 60 * 1000).unref(); + + } + + + /** + * Point in time (ms) a file is deleted. The garbage collector and the + * expiry shown to users share this function so they can not differ. + * One-time downloads that are never fetched expire after maxAge. + */ + expiresAt(file) { + const created = +file.metadata.createdAt; + const maxExpire = created + config.maxAge * 1000; + const retention = +file.metadata.retention; + if(Number.isInteger(retention)) { + return Math.min(maxExpire, created + retention * 1000); + } + return maxExpire; + } + + + init() { + if(this.initialized) return; + this.initialized = true; + + try { + this._sync(); + } catch(e) { + this.initialized = false; + e.message = `db initialization failed with error ${e.message}`; + throw e; + } + } + + + /** + * @private + */ + _sync() { + fsp.ensureDirSync(this.uploadDir); + + fsp.readdirSync(this.uploadDir).forEach((sid) => { + // hidden folders (e.g. .settings) are no buckets + if(sid.startsWith('.')) return; + this._import(sid); + }); + } + + + /** + * @private + */ + _import(sid) { + const p = path.resolve(this.uploadDir, sid); + const stat = fsp.statSync(p); + if(!stat.isDirectory()) return; + + // synchronous on purpose: the index is complete when init() returns + fsp.readdirSync(p).forEach((key) => { + if(path.extname(key) !== '') return; + try { + const file = this.store.getFilename(`${sid}++${key}`); + const info = fsp.readJsonSync(file + '.json'); + const fileStat = fsp.statSync(file); + info.size = fileStat.size; + info.offset = fileStat.size; + this.add(sid, key, info); + } catch(e) { + console.error(e); + } + }); + } + + + add(sid, key, data) { + if(!this.initialized) throw new Error('DB not initialized_'); + if(!this.db[sid]) this.db[sid] = []; + data.key = key; + const old = this.db[sid].findIndex(i => i.key === key); + if(old !== -1) { + this.db[sid].splice(old, 1, data); + debug(`Updated ${sid}++${key}`); + } else { + this.db[sid].push(data); + debug(`Added ${sid}++${key}`); + } + } + + + async remove(sid, key) { + if(!this.initialized) throw new Error('DB not initialized'); + debug(`Remove ${sid}++${key}`); + await this.store.del(sid + '++' + key); + // tolerate concurrent removal of the same file + const bucket = this.get(sid); + if(!bucket) return; + const i = bucket.findIndex(item => item.key === key); + if(i === -1) return; + bucket.splice(i, 1); + if(bucket.length === 0) { + delete this.db[sid]; + try { + await fsp.rmdir(path.resolve(this.uploadDir, sid)); + } catch(e) { + if(e.code !== 'ENOENT' && e.code !== 'ENOTEMPTY') throw e; + } + } + } + + + /** + * Atomically claim a file for a one-time download. + * Must stay synchronous: no await between check and set. + * @returns {boolean} false if already claimed or not (anymore) in the db + */ + claim(sid, key) { + const fid = `${sid}++${key}`; + if(this.claims.has(fid)) return false; + const bucket = this.get(sid); + if(!bucket || !bucket.some(item => item.key === key)) return false; + this.claims.add(fid); + return true; + } + + + release(sid, key) { + this.claims.delete(`${sid}++${key}`); + } + + + isClaimed(sid, key) { + return this.claims.has(`${sid}++${key}`); + } + + + async updateLastDownload(sid, key) { + debug(`Update last download ${sid}++${key}`); + const data = this.get(sid).find(item => item.key === key); + if(!data) return; + data.metadata.lastDownload = Date.now(); + await this.store.update(`${sid}++${key}`, data); + } + + + async updateMetadata(sid, key, data) { + debug(`Update metadata ${ sid }++${ key }`); + const file = this.get(sid).find(item => item.key === key); + if (!file) return; + file.metadata = { ...file.metadata, ...data }; + await this.store.update(`${ sid }++${ key }`, file); + } + + async lock(sid) { + const files = this.get(sid); + if(!files) return; + await Promise.all(files.map(async file => { + await this.updateMetadata(sid, file.key, { bucketLocked: true }); + })); + } + + isLocked(sid) { + const files = this.get(sid); + if(!files) return false; + // "buckedLocked" is the old (misspelled) name, still found in existing data + return files.some(file => file.metadata.bucketLocked || file.metadata.buckedLocked); + } + + /** Remove every file of a bucket. */ + async removeBucket(sid) { + const files = this.get(sid); + if(!files) return 0; + const keys = files.map(f => f.key); + await Promise.all(keys.map(key => this.remove(sid, key))); + return keys.length; + } + + get(sid) { + return this.db[sid]; + } + + bucketSize(sid) { + const bucket = this.get(sid); + if(!bucket) return 0; + return bucket.reduce((v, file) => v + +file.metadata.uploadLength, 0); + } + +}; \ No newline at end of file diff --git a/lib/endpoints.js b/lib/endpoints.js new file mode 100644 index 0000000..7082f80 --- /dev/null +++ b/lib/endpoints.js @@ -0,0 +1,639 @@ +'use strict'; +const pug = require('pug'); +const express = require('express'); +const morgan = require('morgan'); +const compression = require('compression'); +const path = require('path'); +const assert = require('assert'); +const { createHash, randomUUID } = require('node:crypto'); +const httpErrors = require('http-errors'); +const config = require('../config'); +const eventBus = require('./eventBus'); +const tusboy = require('./tusboy'); +const Store = require('./store'); +const Db = require('./db'); +const tusMeta = require('./tusboy/tus-metadata'); +const utils = require('./utils'); +const auth = require('./auth'); +const settings = require('./settings'); +const mime = require('./mime'); +const { pipeArchive } = require('./archive'); +const createAdmin = require('./admin'); +const debug = require('debug')('psitransfer:main'); +const { hashPassword, verifyPassword } = require('./passwordHash'); + +/** + * Naming: + * sid: Group of files (bucket) + * key: File + * fid: {sid}++{key} + */ + +// Archive links of older versions used md5 +function md5Hex(input) { + return createHash('md5').update(input).digest('hex'); +} + +function sha256Hex(input) { + return createHash('sha256').update(input).digest('hex'); +} + +/** Decoded path segment under the /files mount (must match req.params used by tusboy). */ +function decodedUploadPathSegment(req) { + const raw = req.path.startsWith('/') ? req.path.slice(1) : req.path; + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} + +// Pages are a thin shell, the apps are rendered by the browser +const pageTemplate = pug.compileFile(path.join(__dirname, '../public/pug/page.pug')); +const errorTemplate = pug.compileFile(path.join(__dirname, '../public/pug/error.pug')); + +function pageVars(req, extra = {}) { + const brand = settings.brand(); + return { + baseUrl: config.baseUrl, + uploadAppPath: config.uploadAppPath || config.baseUrl, + brand, + // never allow "" inside the inline JSON + brandJson: JSON.stringify(brand).replace(/ pageTemplate(pageVars(req, { page })); +const sendError = (req, res, status, error) => res.status(status).send(errorTemplate(pageVars(req, { error }))); + +const store = new Store(config.uploadDir); +const db = new Db(config.uploadDir, store); +db.init(); +const app = express(); + +app.disable('x-powered-by'); +app.use(compression()); +app.use(express.json({ limit: '100kb' })); + +if (config.accessLog) { + app.use(morgan(config.accessLog)); +} + +if (config.trustProxy) { + app.set('trust proxy', config.trustProxy); +} + +if (config.forceHttps) { + app.enable('trust proxy'); + app.use(function(req, res, next) { + if (req.secure) return next(); + const target = config.forceHttps === 'true' ? 'https://' + req.headers.host : config.forceHttps; + res.redirect(target + req.url); + }); +} + +// Health check for Docker and monitoring: no login, no data +app.get(`${ config.baseUrl }healthz`, (req, res) => { + res.set('Cache-Control', 'no-store'); + res.type('text/plain').send('ok'); +}); + +// Logo and favicon: the one set on the admin page or the built-in one +app.get(`${ config.baseUrl }assets/brand-logo`, (req, res) => { + const logo = settings.logoInfo(); + res.set({ + 'Content-Type': logo.mime, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", + // the url carries a hash of the image (?v=), a new logo gets a new url + 'Cache-Control': 'public, max-age=86400', + }); + // the settings folder is a hidden folder (.settings) + res.sendFile(logo.path, { dotfiles: 'allow' }); +}); + +// Static files +app.use(`${ config.baseUrl }app`, express.static(path.join(__dirname, '../public/app'))); +app.use(`${ config.baseUrl }assets`, express.static(path.join(__dirname, '../public/assets'))); + +// Resolve language +app.use((req, res, next) => { + // without the header acceptsLanguages() would answer with the first language of the list + const lang = (req.get('accept-language') && req.acceptsLanguages(...Object.keys(config.languages))) + || config.defaultLanguage; + req.translations = config.languages[lang]; + next(); +}); + +// robots.txt +app.get(`${ config.baseUrl }robots.txt`, (req, res) => { + res.sendFile(path.join(__dirname, '../public/robots.txt')); +}); + +// Init plugins +config.plugins.forEach(pluginName => { + require('../plugins/' + pluginName)(eventBus, app, config, db); +}); + +// Upload App +app.get(config.uploadAppPath, (req, res) => { + res.send(renderPage(req, 'upload')); +}); + +// Return translations +app.get(`${ config.baseUrl }lang.json`, (req, res) => { + eventBus.emit('getLang', req.translations); + res.json(req.translations); +}); + +// Branding (name, logo, footer) +app.get(`${ config.baseUrl }brand.json`, (req, res) => { + res.set('Cache-Control', 'no-store'); + res.json(settings.brand()); +}); + +// Login: exchange the upload password for a token which is renewed on every use +app.post(`${ config.baseUrl }login`, (req, res) => { + if (!config.uploadPass) return res.json({ authRequired: false }); + const password = req.body && req.body.password; + if (typeof password !== 'string' || !auth.safeEqual(password, config.uploadPass)) { + setTimeout(() => res.status(403).json({ message: 'Forbidden' }), 500); + return; + } + const { token, exp } = auth.issue({}, config.tokenTtl); + res.set('Cache-Control', 'no-store'); + res.json({ token, exp }); +}); + +// Config +app.get(`${ config.baseUrl }config.json`, (req, res) => { + // Upload password protection + if (!auth.uploadAuth(req)) return auth.deny(req, res, 200); + + const frontendConfig = { + retentions: config.retentions, + quickRetentions: config.quickRetentions, + defaultRetention: config.defaultRetention, + mailTemplate: config.mailTemplate, + requireBucketPassword: config.requireBucketPassword, + maxFileSize: config.maxFileSize, + maxBucketSize: config.maxBucketSize, + disableQrCode: config.disableQrCode, + authRequired: !!config.uploadPass, + }; + // every use renews the login: the token is valid for tokenTtl from now on + if (config.uploadPass) { + frontendConfig.session = auth.issue({}, config.tokenTtl); + } + + eventBus.emit('getFrontendConfig', frontendConfig); + + res.set('Cache-Control', 'no-store'); + res.json(frontendConfig); +}); + +// Admin: page, file management, settings +app.use(`${ config.baseUrl }admin`, createAdmin({ db, store, renderPage })); + +const completeFiles = sid => (db.get(sid) || []).filter(file => !file.isPartial); +const archiveToken = files => sha256Hex(files.map(f => f.key).join()).slice(0, 32); + +function publicItem(sid, item) { + // the password hash stays on the server + const { password, ...metadata } = item.metadata; + const oneTime = metadata.retention === 'one-time'; + return { + ...item, + metadata: { ...metadata, hasPassword: !!password }, + url: `${ config.baseUrl }files/${ sid }++${ item.key }`, + // one-time files show no content before they are downloaded + thumbUrl: metadata.thumb && !oneTime ? `${ config.baseUrl }files/${ sid }++${ item.key }.thumb` : null, + previewType: oneTime ? null : mime.previewKind(metadata.name), + expiresAt: db.expiresAt(item), + }; +} + +// List files / Download App +app.get(`${ config.baseUrl }:sid`, async (req, res, next) => { + if (req.params.sid.endsWith('.json')) { + const sid = req.params.sid.slice(0, -5); + const files = completeFiles(sid); + if (!files.length) return res.status(404).end(); + + res.header('Cache-control', 'private, max-age=0, no-cache, no-store, must-revalidate'); + + // Currently, every item in a bucket must have the same password. + // Files uploaded with "internalBypass" are open for everyone with a valid login. + const guarded = files.filter(item => item.metadata.password); + const bypass = guarded.length > 0 + && guarded.every(item => item.metadata.internalBypass === 'true') + && auth.hasSession(req); + try { + if (!bypass) { + const pass = req.get('x-download-pass') || ''; + for (const item of guarded) { + const ok = await verifyPassword(item.metadata.password, pass); + if (!ok) { + setTimeout(() => res.status(401).send('Unauthorized'), 500); + return; + } + } + } + } catch (e) { + console.error(e); + setTimeout(() => res.status(401).send('Unauthorized'), 500); + return; + } + + const items = files.map(item => publicItem(sid, item)); + res.json({ + items, + archiveToken: archiveToken(files), + bypassed: bypass, + oneTime: items.every(item => item.metadata.retention === 'one-time'), + expiresAt: Math.min(...items.map(item => item.expiresAt)), + // a valid login is renewed by using the download page as well + session: auth.hasSession(req) ? auth.issue({}, config.tokenTtl) : null, + config: { + maxPreviewSize: config.maxPreviewSize + } + }); + } else { + if (!completeFiles(req.params.sid).length) return next(); + res.send(renderPage(req, 'download')); + } +}); + +// Thumbnail of a file +function sendThumb(req, res, fid) { + const notFound = () => sendError(req, res, 404, 'File not found'); + if (!utils.isSafeTusUploadId(fid)) return notFound(); + const [sid, key] = fid.split('++'); + const item = completeFiles(sid).find(file => file.key === key); + if (!item || !item.metadata.thumb || item.metadata.retention === 'one-time') return notFound(); + res.set({ + 'Content-Type': item.metadata.thumb, + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'private, max-age=3600', + }); + res.sendFile(store.thumbFilename(fid), err => { + if (err && !res.headersSent) notFound(); + }); +} + +// Download files +app.get(`${ config.baseUrl }files/:fid`, async (req, res, next) => { + // let tusboy handle HEAD requests with Tus Header + if (req.method === 'HEAD' && req.get('Tus-Resumable')) return next(); + + // Disable HTTP transport compression for file downloads. + // Archives already handle their own compression (zip/gzip), and for single + // files this preserves Content-Length and Range request support needed for + // resumable downloads of large files. + res.set('Cache-Control', 'no-transform'); + + const sid = req.params.fid.split('++')[0]; + + if (req.params.fid.endsWith('.thumb')) { + return sendThumb(req, res, req.params.fid.slice(0, -'.thumb'.length)); + } + + // Download all files + const archiveMatch = /^(.+)\+\+([a-f0-9]{32})\.(tar\.gz|zip)$/i.exec(req.params.fid); + if (archiveMatch) { + const format = archiveMatch[3] === 'zip' ? 'zip' : 'tar.gz'; + const bucket = completeFiles(sid); + + if (!bucket.length) return sendError(req, res, 404, 'Download bucket not found.'); + + const keyList = bucket.map(f => f.key).join(); + const expectedLegacy = `${ sid }++${ md5Hex(keyList) }.${ format }`; + const expectedNew = `${ sid }++${ sha256Hex(keyList).slice(0, 32) }.${ format }`; + + if (req.params.fid !== expectedLegacy && req.params.fid !== expectedNew) { + return sendError(req, res, 404, 'Invalid link'); + } + debug(`Download Bucket ${ sid }`); + + const isHead = req.method === 'HEAD'; + const files = bucket.slice(); + + // atomically claim all one-time files; HEAD neither claims nor consumes + const claimed = []; + const release = () => claimed.splice(0).forEach(info => db.release(sid, info.key)); + for (const info of files.filter(info => info.metadata.retention === 'one-time')) { + if (isHead ? db.isClaimed(sid, info.key) : !db.claim(sid, info.key)) { + release(); + return sendError(req, res, 404, 'File not found'); + } + if (!isHead) claimed.push(info); + } + + const filename = `${ sid }.${ format }`; + res.header('Content-Disposition', utils.contentDisposition(filename, 'files')); + + // release the claims on aborted downloads + res.on('close', () => { + if (!res.writableFinished) release(); + }); + + // remove one-time files after download + res.on('finish', async () => { + if (isHead || res.statusCode !== 200) return release(); + let removeFailed = false; + try { + await Promise.all(files.map(info => info.metadata.retention === 'one-time' + ? db.remove(info.metadata.sid, info.metadata.key) + : db.updateLastDownload(info.metadata.sid, info.metadata.key) + )); + + eventBus.emit('archiveDownloaded', { + sid, + file: filename, + metadata: files[0].metadata, + bucket: files, + url: req.protocol + '://' + req.get('host') + req.originalUrl, + }); + } + catch (e) { + console.error(e); + removeFailed = true; + } + // keep the claims if removal failed to prevent further downloads + if (!removeFailed) release(); + }); + + return pipeArchive(res, bucket, format, store); + } + + // Download single file + debug(`Download ${ req.params.fid }`); + try { + if (req.params.fid.includes('++') && !utils.isSafeTusUploadId(req.params.fid)) { + return sendError(req, res, 404, 'Invalid link'); + } + const info = await store.info(req.params.fid); // throws on 404 + const isHead = req.method === 'HEAD'; + const oneTime = info.metadata.retention === 'one-time'; + + // Inline preview: only for types a browser can show, never for one-time files + // (looking at them would use them up). It is no download: not counted, no webhook. + const inline = req.query.inline === '1'; + const previewType = inline ? mime.previewMime(info.metadata.name) : null; + if (inline && (!previewType || oneTime)) { + return sendError(req, res, 404, 'No preview available'); + } + + // atomically claim one-time files; HEAD neither claims nor consumes + let claimed = false; + if (oneTime) { + claimed = !isHead && db.claim(info.metadata.sid, info.metadata.key); + if (isHead ? db.isClaimed(info.metadata.sid, info.metadata.key) : !claimed) { + return sendError(req, res, 404, 'File not found'); + } + } + const release = () => { + if (!claimed) return; + claimed = false; + db.release(info.metadata.sid, info.metadata.key); + }; + + if (inline) { + res.set({ + 'Content-Type': previewType, + 'Content-Disposition': 'inline', + 'X-Content-Type-Options': 'nosniff', + }); + // the sandbox keeps scripts of uploaded files from running, but it also + // switches off the pdf viewer of the browsers + if (previewType !== 'application/pdf') { + res.set('Content-Security-Policy', "default-src 'none'; img-src data:; media-src 'self'; style-src 'unsafe-inline'; sandbox"); + } + } else { + res.set('Content-Disposition', utils.contentDisposition(info.metadata.name, info.key)); + } + res.sendFile(store.getFilename(req.params.fid), err => { + if (!err) return; + release(); + if (!res.headersSent) next(err); + }); + + // release the claim on aborted downloads + res.on('close', () => { + if (!res.writableFinished) release(); + }); + + // remove one-time files after download + res.on('finish', async () => { + if (isHead || inline || ![200, 206].includes(res.statusCode)) return release(); + let removeFailed = false; + try { + if (oneTime) { + await db.remove(info.metadata.sid, info.metadata.key); + } else { + await db.updateLastDownload(info.metadata.sid, info.metadata.key); + } + + eventBus.emit('fileDownloaded', { + sid, + file: info.metadata.name, + metadata: info.metadata, + url: req.protocol + '://' + req.get('host') + req.originalUrl, + }); + } + catch (e) { + console.error(e); + removeFailed = oneTime; + } + // keep the claim if removal failed to prevent further downloads + if (!removeFailed) release(); + }); + } + catch (e) { + sendError(req, res, 404, e.message); + } +}); + + +// Upload a thumbnail for a finished upload (made by the browser of the uploader) +app.put(`${ config.uploadAppPath }files/:fid`, + express.raw({ type: () => true, limit: '64kb' }), + async (req, res, next) => { + try { + if (!auth.uploadAuth(req)) return auth.deny(req, res); + if (!req.params.fid.endsWith('.thumb')) return res.status(404).end(); + const fid = req.params.fid.slice(0, -'.thumb'.length); + if (!utils.isSafeTusUploadId(fid)) return res.status(400).end('Invalid upload id'); + const [sid, key] = fid.split('++'); + const item = completeFiles(sid).find(file => file.key === key); + if (!item) return res.status(404).end('Upload not found'); + if (db.isLocked(sid)) return res.status(400).end('Bucket locked'); + const type = mime.sniffImage(req.body); + if (!type) return res.status(400).end('Invalid thumbnail'); + await store.writeThumb(fid, req.body); + await db.updateMetadata(sid, key, { thumb: type }); + res.status(204).end(); + } catch (e) { + next(e); + } + } +); + + +// Upload file +app.use(`${ config.uploadAppPath }files`, + async function(req, res, next) { + // Upload password protection + if (!auth.uploadAuth(req)) return auth.deny(req, res); + + if (req.method === 'GET') return res.status(405).end(); + + const fid = decodedUploadPathSegment(req); + if (fid === null) { + return res.status(400).end('Invalid path encoding'); + } + + // Lock bucket by PATCH /files/:sid?lock=yes + if (fid && !fid.includes('++') && req.method === 'PATCH' && req.query.lock) { + if (!utils.isSafeBucketFid(fid)) { + return res.status(400).end('Invalid bucket id'); + } + await db.lock(fid); + return res.status(204).end('Bucket locked'); + } + + if (['POST', 'PATCH'].includes(req.method)) { + if (fid && !fid.includes('++') && !utils.isSafeBucketFid(fid)) { + return res.status(400).end('Invalid bucket id'); + } + if (fid && !fid.includes('++') && db.isLocked(fid)) { + return res.status(400).end('Bucket locked'); + } + if (fid) { + if (fid.includes('++') && !utils.isSafeTusUploadId(fid)) { + return res.status(400).end('Invalid upload id'); + } + if (fid.includes('++') && db.isLocked(fid.split('++')[0])) { + return res.status(400).end('Bucket locked'); + } + try { + const info = await store.info(fid); + if (!info.isPartial) { + return res.status(400).end('Upload already completed'); + } + } catch (e) { + if (!(e instanceof httpErrors.NotFound)) { + console.error(e); + return next(e); + } + } + } + } + + if (req.method === 'POST') { + // validate meta-data + // !! tusMeta.encode supports only strings !! + const meta = tusMeta.decode(req.get('Upload-Metadata')); + + try { + assert(meta.name, 'tus meta prop missing: name'); + assert(meta.sid, 'tus meta prop missing: sid'); + if (!utils.isSafeBasename(meta.sid)) { + return res.status(400).end('Invalid bucket id'); + } + assert(meta.retention, 'tus meta prop missing: retention'); + assert(Object.keys(config.retentions).indexOf(meta.retention) >= 0, + `invalid tus meta prop retention. Value ${ meta.retention } not in [${ Object.keys(config.retentions).join(',') }]`); + + // Prevent ZipSlip/tar path traversal by requiring a safe basename at upload time. + // Policy (flat archive): no directories, no absolute paths, no traversal, no control chars. + if (!utils.isSafeBasename(meta.name)) { + return res.status(400).end('Invalid file name'); + } + + const uploadLength = req.get('Upload-Length'); + assert(uploadLength, 'missing Upload-Length header'); + + // Restrict creating new files for locked buckets + if(db.isLocked(meta.sid)) { + return res.status(400).end('Bucket locked'); + } + + // these are set by the server only + for (const reserved of ['thumb', 'lastDownload', 'bucketLocked', 'buckedLocked']) { + delete meta[reserved]; + } + + const password = typeof meta.password === 'string' && meta.password.length > 0 ? meta.password : ''; + + // A password is mandatory, except for one-time downloads + if (config.requireBucketPassword && meta.retention !== 'one-time' && !password) { + return res.status(400).json({ message: 'A password is required for this retention.' }); + } + + meta.uploadLength = uploadLength; + meta.key = randomUUID(); + meta.createdAt = Date.now().toString(); + + // limit file and bucket size + if (config.maxFileSize && config.maxFileSize < +uploadLength) { + return res + .status(413) + .json({ message: `File exceeds maximum upload size ${ config.maxFileSize }.` }); + } else if (config.maxBucketSize && db.bucketSize(meta.sid) + +uploadLength > config.maxBucketSize) { + return res + .status(413) + .json({ message: `Bucket exceeds maximum upload size ${ config.maxBucketSize }.` }); + } + + // store changed metadata for tusboy + if (password) { + meta.password = await hashPassword(password); + // logged in users may skip the password, only meaningful with a login + if (meta.internalBypass === 'true' && config.uploadPass) meta.internalBypass = 'true'; + else delete meta.internalBypass; + } else { + delete meta.password; + delete meta.internalBypass; + } + req.headers['upload-metadata'] = tusMeta.encode(meta); + // for tusboy getKey() + req.FID = meta.sid + '++' + meta.key; + + db.add(meta.sid, meta.key, { + "isPartial": true, + metadata: meta + }); + } + catch (e) { + console.error(e); + return res.status(400).end(e.message); + } + } + + next(); + }, + + // let tusboy handle the upload + tusboy(store, { + getKey: req => req.FID, + maxUploadLength: config.maxFileSize || Infinity, + afterComplete: (req, upload, fid) => { + db.add(upload.metadata.sid, upload.metadata.key, upload); + debug(`Completed upload ${ fid }, size=${ upload.size } name=${ upload.metadata.name }`); + eventBus.emit('fileUploaded', upload); + }, + }) +); + +app.use((req, res, next) => { + if (req.url === config.baseUrl) { + return res.redirect(config.uploadAppPath); + } + + sendError(req, res, 404, 'Download bucket not found.'); +}); + +module.exports = app; diff --git a/lib/eventBus.js b/lib/eventBus.js new file mode 100644 index 0000000..c96eccf --- /dev/null +++ b/lib/eventBus.js @@ -0,0 +1,8 @@ +const EventEmitter = require('events'); +const emitter = new EventEmitter(); + +emitter.on('uncaughtException', function(err) { + console.error(err); +}); + +module.exports = emitter; diff --git a/lib/mime.js b/lib/mime.js new file mode 100644 index 0000000..ee57c53 --- /dev/null +++ b/lib/mime.js @@ -0,0 +1,48 @@ +'use strict'; +const path = require('path'); + +// Types a browser can show inline. The type is derived from the file name on the +// server, the type the client claimed at upload time is never trusted. +// Text is always served as text/plain, html is deliberately not listed. +const MEDIA = { + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', svg: 'image/svg+xml', + pdf: 'application/pdf', + mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', ogv: 'video/ogg', + mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', flac: 'audio/flac', +}; + +const TEXT = ['txt', 'md', 'log', 'json', 'xml', 'csv', 'tsv', 'yml', 'yaml', 'ini', 'conf', 'cfg', + 'js', 'mjs', 'ts', 'jsx', 'tsx', 'vue', 'css', 'scss', 'less', 'sh', 'bat', 'cmd', 'ps1', 'py', + 'java', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'sql', 'lua', 'pug', 'go', 'rs', 'rb']; + +const PREVIEW = { ...MEDIA }; +TEXT.forEach(ext => { PREVIEW[ext] = 'text/plain; charset=utf-8'; }); + +function extname(name) { + return path.extname(String(name || '')).slice(1).toLowerCase(); +} + +/** Content-Type for inline preview or null when the file has to be downloaded. */ +function previewMime(name) { + return PREVIEW[extname(name)] || null; +} + +/** 'image' | 'pdf' | 'video' | 'audio' | 'text' | null */ +function previewKind(name) { + const mime = previewMime(name); + if (!mime) return null; + if (mime === 'application/pdf') return 'pdf'; + return mime.split('/')[0]; +} + +/** Detect the image type of a thumbnail by its signature. */ +function sniffImage(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 12) return null; + if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'; + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; + if (buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'; + return null; +} + +module.exports = { previewMime, previewKind, sniffImage }; diff --git a/lib/passwordHash.js b/lib/passwordHash.js new file mode 100644 index 0000000..bbf5c11 --- /dev/null +++ b/lib/passwordHash.js @@ -0,0 +1,48 @@ +const { hash, verify, Algorithm } = require('@node-rs/argon2'); +const { timingSafeEqual } = require('node:crypto'); + +function isArgon2Hash(value) { + return typeof value === 'string' && value.startsWith('$argon2'); +} + +function safeEqualUtf8(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const aBuf = Buffer.from(a, 'utf8'); + const bBuf = Buffer.from(b, 'utf8'); + if (aBuf.length !== bBuf.length) return false; + return timingSafeEqual(aBuf, bBuf); +} + +async function hashPassword(password) { + // Keep parameters modest for broad hardware support (incl. Raspberry Pi), + // while still using Argon2id. + return hash(password, { + algorithm: Algorithm.Argon2id, + timeCost: 3, + memoryCost: 8192, // KiB (8 MiB) + parallelism: 1, + outputLen: 32, + }); +} + +async function verifyPassword(stored, provided) { + const pass = typeof provided === 'string' ? provided : ''; + + if (isArgon2Hash(stored)) { + try { + return await verify(stored, pass); + } catch { + return false; + } + } + + // Backward compatibility: legacy plaintext stored in metadata JSON. + return safeEqualUtf8(String(stored ?? ''), pass); +} + +module.exports = { + isArgon2Hash, + hashPassword, + verifyPassword, +}; + diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..e51964e --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,167 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { createHash } = require('node:crypto'); +const config = require('../config'); + +// Settings changed on the admin page. They live in (inside the +// data volume), so they survive container re-creation. +// settings.json { name, owner, from, logo } +// logo. custom logo + +const DEFAULT_LOGO = path.join(__dirname, '../public/assets/logo.svg'); +const MAX_LOGO_SIZE = 1024 * 1024; + +class SettingsError extends Error { + constructor(message) { + super(message); + this.status = 400; + } +} + +let cache = null; +let logoCache = null; + +const settingsFile = () => path.join(config.settingsDir, 'settings.json'); + +function load() { + if (cache) return cache; + try { + cache = JSON.parse(fs.readFileSync(settingsFile(), 'utf8')); + } catch (e) { + cache = {}; + } + return cache; +} + +function save() { + fs.mkdirSync(config.settingsDir, { recursive: true }); + const tmp = settingsFile() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(cache, null, 2)); + fs.renameSync(tmp, settingsFile()); +} + +/** Image type by signature, the file extension and the claimed type are ignored. */ +function detectLogo(buf) { + if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return { ext: 'png', mime: 'image/png' }; + } + if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' + && buf.subarray(8, 12).toString('latin1') === 'WEBP') { + return { ext: 'webp', mime: 'image/webp' }; + } + const head = buf.subarray(0, Math.min(buf.length, 4096)).toString('utf8').replace(/^/, '').trimStart(); + if (/^(<\?xml[^>]*\?>\s*)?(\s*)*(]*>\s*)?]/i.test(head)) { + if (/ from ? `${ from }–${ year }` : `${ from }`; + return `© ${ years }${ owner ? ' ' + owner : '' }`; +} + +function defaults() { + return { name: config.appName, owner: config.appOwner, from: config.appFromYear }; +} + +function brand() { + const s = load(); + const d = defaults(); + const name = s.name || d.name; + const owner = s.owner !== undefined ? s.owner : d.owner; + const from = s.from || d.from; + const logo = logoInfo(); + return { + name, + owner, + from, + footer: footerText(owner, from), + logoUrl: `${ config.baseUrl }assets/brand-logo?v=${ logo.hash }`, + customLogo: logo.custom, + }; +} + +function update(input = {}) { + const s = load(); + const next = { ...s }; + if (input.name !== undefined) { + const name = String(input.name).trim(); + if (!name || name.length > 40) throw new SettingsError('name must be 1-40 characters'); + next.name = name; + } + if (input.owner !== undefined) { + const owner = String(input.owner).trim(); + if (owner.length > 60) throw new SettingsError('owner must be at most 60 characters'); + next.owner = owner; + } + if (input.from !== undefined) { + const from = parseInt(input.from, 10); + if (!Number.isInteger(from) || from < 2000 || from > 2100) throw new SettingsError('from must be a year between 2000 and 2100'); + next.from = from; + } + cache = next; + save(); + return brand(); +} + +function removeLogoFiles(except) { + for (const ext of ['png', 'webp', 'svg']) { + if (ext === except) continue; + try { fs.unlinkSync(path.join(config.settingsDir, `logo.${ ext }`)); } catch (e) { /* not there */ } + } +} + +function setLogo(buf) { + if (!Buffer.isBuffer(buf) || buf.length === 0) throw new SettingsError('no image given'); + if (buf.length > MAX_LOGO_SIZE) throw new SettingsError('logo must be at most 1MB'); + const type = detectLogo(buf); + if (!type) throw new SettingsError('logo must be a PNG, WebP or SVG image'); + fs.mkdirSync(config.settingsDir, { recursive: true }); + fs.writeFileSync(path.join(config.settingsDir, `logo.${ type.ext }`), buf); + removeLogoFiles(type.ext); + const s = load(); + cache = { ...s, logo: type.ext }; + save(); + logoCache = null; + return brand(); +} + +function clearLogo() { + const s = load(); + const { logo, ...rest } = s; + cache = rest; + save(); + removeLogoFiles(null); + logoCache = null; + return brand(); +} + +/** for tests */ +function reset() { + cache = null; + logoCache = null; +} + +module.exports = { brand, defaults, update, setLogo, clearLogo, logoInfo, footerText, detectLogo, reset, SettingsError, MAX_LOGO_SIZE }; diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..5a63702 --- /dev/null +++ b/lib/store.js @@ -0,0 +1,155 @@ +'use strict'; +const fsp = require('fs-promise'); +const path = require('path'); +const Transform = require('stream').Transform; +const debug = require('debug')('psitransfer:store'); +const httpErrors = require('http-errors'); + +class StreamLen extends Transform { + constructor(options) { + super(options); + this.bytes = 0; + } + + _transform(chunk, encoding, cb) { + this.bytes += chunk.length; + this.push(chunk); + cb(); + } +} + +// TODO ???: make tus-store complaint: https://github.com/blockai/abstract-tus-store +class Store { + + constructor(targetDir) { + this.dir = path.resolve(targetDir); + // fids with a deletion in progress + this._deleting = new Map(); + } + + + getFilename(fid) { + if (typeof fid !== 'string' || fid.includes('\0')) { + throw new Error('file name not in jail path. aborting'); + } + const base = this.dir; + const resolved = path.resolve(base, fid.replace(/\+\+/g, '/')); + const rel = path.relative(base, resolved); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('file name not in jail path. aborting'); + } + return resolved; + } + + + async create(fid, opts = {}) { + debug(`New File ${this.getFilename(fid)}`); + await fsp.ensureDir(path.dirname(this.getFilename(fid))); + await fsp.writeJson(this.getFilename(fid) + '.json', Object.assign(opts, { + isPartial: true + })); + return {uploadId: fid}; + } + + + async update(fid, data) { + debug(`Update File ${this.getFilename(fid)}`); + await fsp.writeJson(this.getFilename(fid) + '.json', data); + return data; + } + + + async info(fid) { + try { + const info = await fsp.readJson(this.getFilename(fid) + '.json'); + const stat = await fsp.stat(this.getFilename(fid)); + info.size = stat.size; + info.offset = stat.size; + debug(`Fetched Fileinfo ${this.getFilename(fid)}`); + return info; + } catch(e) { + if(e.code === 'ENOENT') { + throw httpErrors.NotFound(); + } + throw e; + } + } + + + async append(fid, readStream, offset) { + debug(`Append Data to ${this.getFilename(fid)}`); + const uploadSize = new StreamLen(); + // No `start`: a positional write on an O_APPEND fd fails with EOPNOTSUPP on + // musl >= 1.2.5 with kernels < 6.9 (no RWF_NOAPPEND); Linux ignores it anyway + const ws = fsp.createWriteStream(this.getFilename(fid), {flags: 'a'}); + + const ret = new Promise((resolve, reject) => { + ws.on('finish', async() => { + try { + const info = await this.info(fid); + if(info.size >= info.uploadLength) delete info.isPartial; + await fsp.writeJson(this.getFilename(fid) + '.json', info); + debug(`Finished appending Data to ${this.getFilename(fid)}`); + return resolve({ offset: info.offset, upload: info }); + } catch (e) { + return reject(e); + } + }); + ws.on('error', reject); + }); + + readStream.pipe(uploadSize).pipe(ws); + return ret; + } + + + createReadStream(fid, start, end, cb) { + debug(`Create ReadStream for ${this.getFilename(fid)}`); + this.info(fid).then(info => { + let contentLength = info.size; + if(start > 0) { + if(!end) end = info.size - 1; + contentLength = end - start + 1 + } + if(cb) cb({ contentLength, metadata: info.metadata, info }); + }); + return fsp.createReadStream(this.getFilename(fid), {start, end}); + } + + + thumbFilename(fid) { + return this.getFilename(fid) + '.thumb'; + } + + + async writeThumb(fid, buffer) { + debug(`Write thumbnail ${this.thumbFilename(fid)}`); + await fsp.writeFile(this.thumbFilename(fid), buffer); + } + + + /** + * Delete a file with its metadata and thumbnail. Concurrent deletions of the + * same file share one run (Windows fails when a file is deleted twice at once). + */ + del(fid) { + if (this._deleting.has(fid)) return this._deleting.get(fid); + const run = this._del(fid).finally(() => this._deleting.delete(fid)); + this._deleting.set(fid, run); + return run; + } + + + async _del(fid) { + debug(`Delete ${this.getFilename(fid)}`); + for (const suffix of ['.json', '.thumb', '']) { + try { + await fsp.unlink(this.getFilename(fid) + suffix); + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } + } + } +} + +module.exports = Store; diff --git a/lib/tusboy/constants.js b/lib/tusboy/constants.js new file mode 100644 index 0000000..294f16a --- /dev/null +++ b/lib/tusboy/constants.js @@ -0,0 +1,35 @@ +const HEADERS = [ + 'Content-Type', + 'Location', + 'Tus-Extension', + 'Tus-Max-Size', + 'Tus-Resumable', + 'Tus-Version', + 'Upload-Defer-Length', + 'Upload-Length', + 'Upload-Metadata', + 'Upload-Offset', + 'X-HTTP-Method-Override', + 'X-Requested-With', +]; + +const MAX_AGE = 86400; +const ALLOWED_HEADERS = HEADERS; +const EXPOSED_HEADERS = HEADERS; + +const ALLOWED_METHODS = [ + 'POST', + 'HEAD', + 'PATCH', + 'OPTIONS', +]; + +const TUS_VERSION = '1.0.0'; + +module.exports = { + MAX_AGE, + ALLOWED_HEADERS, + EXPOSED_HEADERS, + ALLOWED_METHODS, + TUS_VERSION, +} diff --git a/lib/tusboy/errors.js b/lib/tusboy/errors.js new file mode 100644 index 0000000..f6ea0ed --- /dev/null +++ b/lib/tusboy/errors.js @@ -0,0 +1,51 @@ +const createError = require('http-errors'); + +const tusResumableHeaderMissing = () => createError(412, 'Tus-Resumable Required') + +const invalidHeaders = (headers) => ( + createError(412, 'Precondition Failed', { + details: { headers }, + }) +) + +const invalidHeader = (header, val) => ( + createError(412, 'Precondition Failed', { + details: { headers: [[header, val]] }, + }) +) + +const missingHeader = (header) => invalidHeader(header) + +const entityTooLarge = (msg, props) => createError(413, msg, props) + +const preconditionError = (msg, props) => createError(412, msg, props) + +const offsetMismatch = (actual, expected) => ( + createError(409, `Offset mismatch, got ${actual} but expected ${expected}`) +) + +// For store implementations: +const unknownResource = (key) => ( + createError(404, `Unknown resource: ${key}`) +) + +const concurrentWrite = () => ( + createError(409, 'Concurrent write detected') +) + +const uploadLengthAlreadySet = () => ( + createError(409, 'Upload length is already set') +) + +module.exports = { + tusResumableHeaderMissing, + invalidHeaders, + invalidHeader, + missingHeader, + entityTooLarge, + preconditionError, + offsetMismatch, + unknownResource, + concurrentWrite, + uploadLengthAlreadySet, +} diff --git a/lib/tusboy/handlers/error.js b/lib/tusboy/handlers/error.js new file mode 100644 index 0000000..22446b2 --- /dev/null +++ b/lib/tusboy/handlers/error.js @@ -0,0 +1,16 @@ +module.exports = (err, req, res, next) => { + if (res.headersSent) return next(err) + if (!err.status) { + // console.error(err) + return next(err) + } + if (!err.expose) return next(err) + if (!(err instanceof Error)) return next(err) + + res.statusCode = err.status + res.json({ + name: err.name, + message: err.message, + details: err.details, + }) +} diff --git a/lib/tusboy/handlers/head.js b/lib/tusboy/handlers/head.js new file mode 100644 index 0000000..ed925a2 --- /dev/null +++ b/lib/tusboy/handlers/head.js @@ -0,0 +1,51 @@ +// HEAD +// +// The Server MUST always include the Upload-Offset header in the +// response for a HEAD request, even if the offset is 0, or the upload +// is already considered completed. If the size of the upload is known, +// the Server MUST include the Upload-Length header in the response. If +// the resource is not found, the Server SHOULD return either the 404 +// Not Found, 410 Gone or 403 Forbidden status without the Upload-Offset +// header. +// +// The Server MUST prevent the client and/or proxies from caching the +// response by adding the Cache-Control: no-store header to the +// response. +// + +const encodeMetadata = require('../tus-metadata').encode; +const utils = require('../../utils'); +const errors = require('../errors'); + +module.exports = (store) => ( + async (req, res) => { + res.set('Cache-Control', 'no-store') + const { uploadId } = req.params + if (!utils.isSafeTusUploadId(uploadId)) { + throw errors.unknownResource(uploadId) + } + const upload = await store.info(uploadId) + // The Server MUST always include the Upload-Offset header in the + // response for a HEAD request, even if the offset is 0, or the upload + // is already considered completed. + res.set('Upload-Offset', upload.offset) + + if (!('uploadLength' in upload)) { + // As long as the length of the upload is not known, the Server + // MUST set Upload-Defer-Length: 1 in all responses to HEAD requests. + res.set('Upload-Defer-Length', '1') + } else { + // If the size of the upload is known, the Server MUST include + // the Upload-Length header in the response + res.set('Upload-Length', upload.uploadLength) + } + // If an upload contains additional metadata, responses to HEAD + // requests MUST include the Upload-Metadata header and its value as + // specified by the Client during the creation. + const encodedMetadata = encodeMetadata(upload.metadata) + if (encodedMetadata !== '') { + res.set('Upload-Metadata', encodedMetadata) + } + res.end() + } +) diff --git a/lib/tusboy/handlers/options.js b/lib/tusboy/handlers/options.js new file mode 100644 index 0000000..e514af0 --- /dev/null +++ b/lib/tusboy/handlers/options.js @@ -0,0 +1,23 @@ +const cors = require('cors') +const { ALLOWED_HEADERS, ALLOWED_METHODS, MAX_AGE } = require ('../constants'); + +const tusExtension = (extensions = []) => { + if (!extensions.length) { + return (req, res, next) => next() + } + return (req, res, next) => { + res.set('Tus-Extension', extensions.join(',')) + next() + } +} + +const corsPreflight = (extraMethods) => cors({ + methods: [...ALLOWED_METHODS, ...extraMethods], + allowedHeaders: ALLOWED_HEADERS, + maxAge: MAX_AGE, +}) + +module.exports = (extensions, extraMethods = []) => ([ + tusExtension(extensions), + corsPreflight(extraMethods), +]) diff --git a/lib/tusboy/handlers/patch.js b/lib/tusboy/handlers/patch.js new file mode 100644 index 0000000..436b587 --- /dev/null +++ b/lib/tusboy/handlers/patch.js @@ -0,0 +1,87 @@ +// PATCH +// +// The Server SHOULD accept PATCH requests against any upload URL and +// apply the bytes contained in the message at the given offset +// specified by the Upload-Offset header. All PATCH requests MUST use +// Content-Type: application/offset+octet-stream. +// +// The Upload-Offset header’s value MUST be equal to the current offset +// of the resource. In order to achieve parallel upload the +// Concatenation extension MAY be used. If the offsets do not match, the +// Server MUST respond with the 409 Conflict status without modifying +// the upload resource. +// +// The Client SHOULD send all the remaining bytes of an upload in a +// single PATCH request, but MAY also use multiple small requests +// successively for scenarios where this is desirable, for example, if +// the Checksum extension is used. +// +// The Server MUST acknowledge successful PATCH requests with the 204 No +// Content status. It MUST include the Upload-Offset header containing +// the new offset. The new offset MUST be the sum of the offset before +// the PATCH request and the number of bytes received and processed or +// stored during the current PATCH request. +// +// Both, Client and Server, SHOULD attempt to detect and handle network +// errors predictably. They MAY do so by checking for read/write socket +// errors, as well as setting read/write timeouts. A timeout SHOULD be +// handled by closing the underlying connection. +// +// The Server SHOULD always attempt to store as much of the received +// data as possible. +const storeErrors = require('../store/errors'); +const errors = require('../errors'); +const utils = require('../../utils'); + +module.exports = (store, { + onComplete, + beforeComplete = async () => {}, + afterComplete = async () => {}, +}) => (async (req, res) => { + const after = onComplete || afterComplete + + // The request MUST include a Upload-Offset header + if (!('uploadOffset' in req.tus)) { + throw errors.missingHeader('upload-offset') + } + + // The request MUST include a Content-Type header + if (typeof req.get('content-type') === 'undefined') { + throw errors.missingHeader('content-type') + } + + // All PATCH requests MUST use Content-Type: application/offset+octet-stream + if (req.get('content-type') !== 'application/offset+octet-stream') { + throw errors.invalidHeader('content-type', req.get('content-type')) + } + + const uploadId = req.params.uploadId + + if (!utils.isSafeTusUploadId(uploadId)) { + throw errors.unknownResource(uploadId) + } + + try { + const { + offset, + upload, + } = await store.append(uploadId, req, req.tus.uploadOffset, { + beforeComplete: (...args) => beforeComplete(req, ...args), + uploadLength: req.tus.uploadLength, + }) + if (upload && upload.uploadLength === offset) { + await after(req, upload, uploadId) + } + // It MUST include the Upload-Offset header containing the new offset. + res.set('Upload-Offset', offset) + // The Server MUST acknowledge successful PATCH requests + // with the 204 No Content status. + res.status(204) + res.end() + } catch (err) { + if (err instanceof storeErrors.OffsetMismatch) { + throw errors.offsetMismatch() + } + throw err + } +}) diff --git a/lib/tusboy/handlers/post.js b/lib/tusboy/handlers/post.js new file mode 100644 index 0000000..56ebd5f --- /dev/null +++ b/lib/tusboy/handlers/post.js @@ -0,0 +1,105 @@ +// Creation +// +// The Client and the Server SHOULD implement the upload creation +// extension. If the Server supports this extension, it MUST add creation +// to the Tus-Extension header. +// +// Headers +// +// Upload-Defer-Length +// +// The Upload-Defer-Length request and response header indicates that the +// size of the upload is not known currently and will be transferred later. +// Its value MUST be 1. If the length of an upload is not deferred, this +// header MUST be omitted. +// +// Upload-Metadata +// +// The Upload-Metadata request and response header MUST consist of one or +// more comma-separated key-value pairs. The key and value MUST be +// separated by a space. The key MUST NOT contain spaces and commas and +// MUST NOT be empty. The key SHOULD be ASCII encoded and the value MUST be +// Base64 encoded. All keys MUST be unique. +// +// Requests +// +// POST +// +// The Client MUST send a POST request against a known upload creation URL +// to request a new upload resource. The request MUST include one of the +// following headers: +// +// a) Upload-Length to indicate the size of an entire upload in bytes. +// +// b) Upload-Defer-Length: 1 if upload size is not known at the time. Once +// it is known the Client MUST set the Upload-Length header in the next +// PATCH request. Once set the length MUST NOT be changed. As long as the +// length of the upload is not known, the Server MUST set +// Upload-Defer-Length: 1 in all responses to HEAD requests. +// +// If the Server supports deferring length, it MUST add +// creation-defer-length to the Tus-Extension header. +// +// The Client MAY supply the Upload-Metadata header to add additional +// metadata to the upload creation request. The Server MAY decide to ignore +// or use this information to further process the request or to reject it. +// If an upload contains additional metadata, responses to HEAD requests +// MUST include the Upload-Metadata header and its value as specified by +// the Client during the creation. +// +// If the length of the upload exceeds the maximum, which MAY be specified +// using the Tus-Max-Size header, the Server MUST respond with the 413 +// Request Entity Too Large status. +// +// The Server MUST acknowledge a successful upload creation with the 201 +// Created status. The Server MUST set the Location header to the URL of +// the created resource. This URL MAY be absolute or relative. +// +// The Client MUST perform the actual upload using the core protocol. + +const errors = require('../errors'); + +module.exports = (store, { + getKey = () => {}, + maxUploadLength = Infinity, +}) => async (req, res, next) => { + const tus = req.tus + const { uploadLength } = tus + const defer = !!tus.uploadDeferLength + + if (!defer && !('uploadLength' in tus)) { + return next(errors.preconditionError( + 'Missing Upload-Length header' + )) + } + // TODO: make sure store supports defer length + if (defer && !store.supportsDeferredLength) { + return next(errors.preconditionError( + 'Store does not support creation-defer-length extension' + )) + } + if (defer && ('uploadLength' in tus)) { + return next(errors.preconditionError( + 'Choose one of Upload-Length OR Upload-Defer-Length' + )) + } + + if ('uploadLength' in tus && uploadLength > maxUploadLength) { + res.set('Tus-Max-Size', maxUploadLength) + return next(errors.entityTooLarge( + `Upload-length (${uploadLength}) exceeds max upload size (${maxUploadLength})` + ), { maxUploadLength, uploadLength }) + } + + const key = await getKey(req) + const metadata = req.tus.uploadMetadata + const { uploadId } = await store.create(key, { + uploadLength, + metadata, + }) + res.status(201) + let basePath = req.baseUrl + if (basePath[basePath.length - 1] !== '/') basePath += '/' + res.set('Location', `${basePath}${uploadId}`) + res.end() +} diff --git a/lib/tusboy/index.js b/lib/tusboy/index.js new file mode 100644 index 0000000..925aff7 --- /dev/null +++ b/lib/tusboy/index.js @@ -0,0 +1,90 @@ +const { Router } = require('express'); +const tusHeaderParser = require('./tus-header-parser'); +const methodOverride = require('method-override'); +const cors = require('cors'); + +const constants = require('./constants'); +const errors = require('./errors'); + +const errorHandler = require ('./handlers/error'); +const options = require ('./handlers/options'); +const head = require ('./handlers/head'); +const patch = require ('./handlers/patch'); +const post = require ('./handlers/post'); + +/** + * Returns a route handler for Express that calls the passed in function + * @param {Function} fn The asynchronous the route needs to call + * @return {Promise} + */ +function w(fn) { + if (fn.length <= 3) { + return function(req, res, next) { + return fn(req, res, next).catch(next); + }; + } else { + return function(err, req, res, next) { + return fn(err, req, res, next).catch(next); + }; + } +} + +const detectExtensions = (store) => { + return [ + 'create', + () => { if (store.del) return 'delete' }, + ].filter(ele => typeof ele === 'string') +} + +const versionSupported = (/* versionStr */) => true + +const setTusResumableHeader = (req, res, next) => { + res.set('Tus-Resumable', constants.TUS_VERSION) + next() +} + +// The Tus-Resumable header MUST be included in every request and +// response except for OPTIONS requests. The value MUST be the version +// of the protocol used by the Client or the Server. +const assertTusResumableHeader = (req, res, next) => { + if (!('tusResumable' in req.tus)) { + res.set('Tus-Version', constants.TUS_VERSION) + return next(errors.preconditionError('Tus-Resumable header missing')) + } else if (!versionSupported(req.tus.tusResumable)) { + res.set('Tus-Version', constants.TUS_VERSION) + return next(errors.preconditionError('Tus-Resumable version not supported')) + } + next() +} + +const setCorsHeaders = cors({ + origin: true, + exposedHeaders: constants.EXPOSED_HEADERS, +}) + +module.exports = (store, opts = {}) => { + const { handleErrors = true } = opts + const extensions = detectExtensions(store) + + const nextRouter = new Router({ mergeParams: true }) + .use(assertTusResumableHeader) + .post('/', w(post(store, opts))) + .head('/:uploadId', w(head(store, opts))) + .patch('/:uploadId', w(patch(store, opts))) + + if (handleErrors) nextRouter.use(errorHandler) + + const router = new Router({ mergeParams: true }) + router + .use(methodOverride('X-HTTP-Method-Override')) + .use(tusHeaderParser()) + .options(/.*/, options(extensions, opts.extraCorsMethods)) + .use(setCorsHeaders) + .use(setTusResumableHeader) + .use((req, res, next) => { + if (req.method === 'GET') return next() + return nextRouter(req, res, next) + }) + + return router +} diff --git a/lib/tusboy/store/errors.js b/lib/tusboy/store/errors.js new file mode 100644 index 0000000..2e8e053 --- /dev/null +++ b/lib/tusboy/store/errors.js @@ -0,0 +1,20 @@ +class StoreError extends Error { +} + +class UploadNotFound extends StoreError { +} + +class OffsetMismatch extends StoreError { +} + +class KeyNotFound extends StoreError { +} + +class UploadLocked extends StoreError { +} + +module.exports = StoreError; +module.exports.UploadNotFound = UploadNotFound; +module.exports.OffsetMismatch = OffsetMismatch; +module.exports.KeyNotFound = KeyNotFound; +module.exports.UploadLocked = UploadLocked; diff --git a/lib/tusboy/tus-header-parser.js b/lib/tusboy/tus-header-parser.js new file mode 100644 index 0000000..ff32740 --- /dev/null +++ b/lib/tusboy/tus-header-parser.js @@ -0,0 +1,111 @@ +const toObject = require('to-object-reducer'); +const decodeMetadata = require('./tus-metadata').decode; + +// TODO: Buffer.from silently ignores invalid bytes :( +// https://github.com/nodejs/node/issues/8569 +const base64decode = str => Buffer.from(str, 'base64') + +const nonNegativeInteger = (v) => { + // need this because parseInt parses floats too + if (v !== '0' && !v.match(/^[1-9][0-9]*$/)) return + const num = parseInt(v, 10) + if (isNaN(num)) return + if (num >= 0) return num +} + +/* +const enum = (possibleVals = []) => (v) => { + if (!possibleVals.includes(v)) return + return v +} +*/ + +const id = v => v +const parsers = { + 'tus-resumable': id, + // The Upload-Offset request and response header indicates a byte offset + // within a resource. The value MUST be a non-negative integer. + 'upload-offset': nonNegativeInteger, + // The Upload-Defer-Length request and response header indicates that the + // size of the upload is not known currently and will be transferred later. + // Its value MUST be 1. If the length of an upload is not deferred, this + // header MUST be omitted. + 'upload-defer-length': v => { + const num = nonNegativeInteger(v) + if (num === 1) return 1 + }, + // The Upload-Length request and response header indicates the size of the + // entire upload in bytes. The value MUST be a non-negative integer. + 'upload-length': nonNegativeInteger, + 'upload-metadata': decodeMetadata, + // The Upload-Checksum request header contains information about the checksum + // of the current body payload. The header MUST consist of the name of the + // used checksum algorithm and the Base64 encoded checksum separated by a + // space. + 'upload-checksum': v => { + const parts = v.split(' ') + if (parts.length !== 2) return + const algorithm = parts[0] + const checksum = base64decode(parts[1]) + if (!checksum.length) return + return { algorithm, checksum } + }, + // The Upload-Concat request and response header MUST be set in both partial + // and final upload creation requests. It indicates whether the upload is + // either a partial or final upload. If the upload is a partial one, the + // header value MUST be partial. In the case of a final upload, its value + // MUST be final followed by a semicolon and a space-separated list of + // partial upload URLs that will be concatenated. The partial uploads URLs + // MAY be absolute or relative and MUST NOT contain spaces as defined in RFC + // 3986. + 'upload-concat': v => { + if (v === 'partial') return { partial: true } + const parts = v.split(';') + if (!parts.length) return + const firstPart = parts.shift() + if (firstPart !== 'final') return + const urls = parts.join(';').split(' ') + if (urls.some(url => url === '')) return + // validate urls? nah... + return { final: true, urls } + }, +} + +const knownHeaders = Object.keys(parsers) + +const parseHeader = (tmpHeaderName, str) => { + const headerName = tmpHeaderName.toLowerCase() + if (!knownHeaders.includes(headerName)) return + return parsers[headerName](str) +} + +// Pre-computed for speed +const camelCase = { + 'tus-resumable': 'tusResumable', + 'upload-offset': 'uploadOffset', + 'upload-defer-length': 'uploadDeferLength', + 'upload-length': 'uploadLength', + 'upload-metadata': 'uploadMetadata', + 'upload-checksum': 'uploadChecksum', + 'upload-concat': 'uploadConcat', +} + +const parseHeaders = (headers) => ( + Object + .keys(headers) + .filter(headerName => knownHeaders.includes(headerName)) + .map(headerName => ([ + camelCase[headerName], + parsers[headerName](headers[headerName]), + ])) + .reduce(toObject, {}) +) + +module.exports = () => (req, res, next) => { + req.tus = parseHeaders(req.headers) + next() +}; + +module.exports.knownHeaders = knownHeaders; +module.exports.parseHeader = parseHeader; +module.exports.parseHeaders = parseHeaders; diff --git a/lib/tusboy/tus-metadata.js b/lib/tusboy/tus-metadata.js new file mode 100644 index 0000000..873cd7d --- /dev/null +++ b/lib/tusboy/tus-metadata.js @@ -0,0 +1,24 @@ +const toObject = require('to-object-reducer'); + +// return object +const base64decode = str => Buffer.from(str, 'base64').toString('utf8') +const base64encode = str => Buffer.from(str, 'utf8').toString('base64') + +module.exports.decode = (str = '') => { + const keypairs = str + .split(',') + // .map(s => s.trim()) + const keyvals = keypairs + .map(s => s.split(' ')) + .filter(arr => arr.length === 2) + .filter(([key]) => key !== '') + .map(([key, val]) => [key, base64decode(val)]) + return keyvals.reduce(toObject, {}) +} + +module.exports.encode = (obj = {}) => Object + .keys(obj) + .map(key => [key, obj[key]]) + .map(([key, val]) => [key, base64encode(val)]) + .map(kvArr => kvArr.join(' ')) + .join(',') diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..9e12b9a --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,96 @@ +const path = require('path'); + +// Control chars incl. NUL. Keep it strict and cross-platform. +const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g; + +function toSafeBasename(input, fallback = 'file', opts = {}) { + const maxLength = Number.isInteger(opts.maxLength) ? opts.maxLength : 255; + + let name = ''; + if (typeof input === 'string') name = input; + else if (input == null) name = ''; + else name = String(input); + + // Remove NUL/control chars early (these can truncate paths in some tooling). + name = name.replace(CONTROL_CHARS_RE, ''); + + // Normalize separators to POSIX then force basename (flat archives). + name = name.replace(/\\/g, '/'); + name = path.posix.basename(name); + + // Trim to avoid whitespace-only names; keep the caller’s original if they need strict equality. + name = name.trim(); + + // Disallow dot/dotdot and empty. + if (!name || name === '.' || name === '..') name = ''; + + // Length cap. + if (name.length > maxLength) name = name.slice(0, maxLength); + + if (!name) { + const fb = (fallback == null ? '' : String(fallback)).replace(CONTROL_CHARS_RE, '').trim(); + return fb ? fb.slice(0, maxLength) : 'file'; + } + + return name; +} + +function isSafeBasename(name, opts = {}) { + if (typeof name !== 'string') return false; + return toSafeBasename(name, '', opts) === name; +} + +// Node randomUUID() v4 shape (case-insensitive). +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Tus resume URL id: exactly one "++", safe bucket sid, UUID key. + */ +function isSafeTusUploadId(uploadId) { + if (typeof uploadId !== 'string' || uploadId.includes('\0')) return false; + const parts = uploadId.split('++'); + if (parts.length !== 2) return false; + const [sid, key] = parts; + if (!sid || !key) return false; + return isSafeBasename(sid) && UUID_V4_RE.test(key); +} + +/** + * Bucket-only id (lock PATCH, middleware checks without "++"). + */ +function isSafeBucketFid(fid) { + if (typeof fid !== 'string' || fid.includes('++') || fid.includes('\0')) return false; + return isSafeBasename(fid); +} + +function toAsciiFallbackFilename(name, fallback = 'file') { + const safe = toSafeBasename(name, fallback); + const normalized = safe.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); + const ascii = normalized.replace(/[^\x20-\x7E]+/g, '').trim(); + const cleaned = ascii.replace(/["\\]/g, '_'); + if (cleaned) return cleaned; + const fallbackSafe = toSafeBasename(fallback, 'file'); + return fallbackSafe.replace(/[^\x20-\x7E]+/g, '').trim() || 'file'; +} + +/** Content-Disposition value with an ASCII fallback and the UTF-8 name (RFC 5987). */ +function contentDisposition(name, fallback = 'file', type = 'attachment') { + const safe = toSafeBasename(name, fallback); + const asciiFallback = toAsciiFallbackFilename(safe, fallback); + const encoded = encodeURIComponent(safe) + .replace(/['()]/g, c => `%${ c.charCodeAt(0).toString(16).toUpperCase() }`) + .replace(/\*/g, '%2A'); + return `${ type }; filename="${ asciiFallback }"; filename*=UTF-8''${ encoded }`; +} + +module.exports = { + contentDisposition, + toSafeBasename, + isSafeBasename, + isSafeTusUploadId, + isSafeBucketFid, + UUID_V4_RE, +}; + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aebdd4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4197 @@ +{ + "name": "dodo-transfer", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "dodo-transfer", + "version": "1.0.0", + "license": "BSD-2-Clause", + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "archiver": "^8.0.0", + "axios": "^1.16.1", + "compression": "^1.8.1", + "cors": "^2.8.6", + "debug": "^4.4.3", + "express": "^5.2.1", + "fs-promise": "^2.0.3", + "http-errors": "^2.0.1", + "method-override": "^3.0.0", + "morgan": "^1.10.1", + "pug": "^3.0.4", + "tar-stream": "^3.2.0", + "to-object-reducer": "^1.0.1" + }, + "bin": { + "dodo-transfer": "cli.js" + }, + "engines": { + "node": ">= 24" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", + "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", + "integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0" + } + }, + "node_modules/fs-promise": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/fs-promise/-/fs-promise-2.0.3.tgz", + "integrity": "sha1-9k5PhUvPaJqovdy6JokW2z20aFQ=", + "deprecated": "Use mz or fs-extra^3.0 with Promise Support", + "dependencies": { + "any-promise": "^1.3.0", + "fs-extra": "^2.0.0", + "mz": "^2.6.0", + "thenify-all": "^1.6.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", + "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "dependencies": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/morgan": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pug": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", + "license": "MIT", + "dependencies": { + "pug-code-gen": "^3.0.4", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "dependencies": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", + "license": "MIT" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/resolve": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz", + "integrity": "sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA==", + "dependencies": { + "is-core-module": "^2.0.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", + "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-object-reducer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-object-reducer/-/to-object-reducer-1.0.1.tgz", + "integrity": "sha1-gkRSNP9e3EwArUIRqln5bxI1T4Q=" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + } + }, + "dependencies": { + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==" + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" + }, + "@babel/parser": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==" + }, + "@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "requires": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "optional": true, + "requires": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "requires": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "optional": true + }, + "@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "optional": true + }, + "@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "optional": true + }, + "@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "optional": true + }, + "@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "optional": true + }, + "@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "optional": true + }, + "@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "optional": true + }, + "@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "optional": true + }, + "@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "optional": true + }, + "@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "optional": true + }, + "@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "optional": true, + "requires": { + "@napi-rs/wasm-runtime": "^0.2.5" + } + }, + "@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "optional": true + }, + "@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "optional": true + }, + "@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "optional": true + }, + "@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, + "archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "requires": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + } + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==" + }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "requires": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "b4a": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", + "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "requires": {} + }, + "babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "requires": { + "@babel/types": "^7.9.6" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "requires": {} + }, + "bare-fs": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", + "requires": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + } + }, + "bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==" + }, + "bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "requires": { + "bare-os": "^3.0.1" + } + }, + "bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "requires": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + } + }, + "bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "requires": { + "bare-path": "^3.0.0" + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "dependencies": { + "content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==" + } + } + }, + "brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, + "character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "requires": { + "is-regex": "^1.0.3" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "requires": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + } + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "requires": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "requires": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==" + }, + "crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "requires": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + } + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "requires": { + "ms": "^2.1.3" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "requires": { + "bare-events": "^2.7.0" + } + }, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, + "follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==" + }, + "form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "fs-extra": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", + "integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0" + } + }, + "fs-promise": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/fs-promise/-/fs-promise-2.0.3.tgz", + "integrity": "sha1-9k5PhUvPaJqovdy6JokW2z20aFQ=", + "requires": { + "any-promise": "^1.3.0", + "fs-extra": "^2.0.0", + "mz": "^2.6.0", + "thenify-all": "^1.6.0" + } + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "requires": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-core-module": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", + "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", + "requires": { + "has": "^1.0.3" + } + }, + "is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "requires": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "requires": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "requires": { + "readable-stream": "^2.0.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "requires": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "morgan": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", + "requires": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, + "proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" + }, + "pug": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", + "requires": { + "pug-code-gen": "^3.0.4", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "requires": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", + "requires": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "requires": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "requires": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "requires": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "requires": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "requires": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + }, + "pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "requires": { + "pug-error": "^2.0.0" + } + }, + "pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, + "qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "requires": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + } + }, + "readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + }, + "readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "requires": { + "minimatch": "^10.2.2" + } + }, + "resolve": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz", + "integrity": "sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA==", + "requires": { + "is-core-module": "^2.0.0", + "path-parse": "^1.0.6" + } + }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "dependencies": { + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + } + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "requires": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, + "streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "requires": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "requires": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "requires": { + "streamx": "^2.12.5" + } + }, + "text-decoder": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", + "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "requires": { + "b4a": "^1.6.4" + } + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "to-object-reducer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-object-reducer/-/to-object-reducer-1.0.1.tgz", + "integrity": "sha1-gkRSNP9e3EwArUIRqln5bxI1T4Q=" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=" + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "optional": true + }, + "type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "requires": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "dependencies": { + "content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, + "with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "requires": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "requires": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7fc21a --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "dodo-transfer", + "version": "1.0.0", + "description": "Self-hosted file transfer with upload links, passwords, one-time downloads and previews", + "keywords": [ + "share", + "upload", + "transfer", + "files", + "self-hosted" + ], + "repository": { + "type": "git", + "url": "https://git.dodo-lab.kr/blue2rai/DodoTransfer.git" + }, + "main": "app.js", + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "archiver": "^8.0.0", + "axios": "^1.16.1", + "compression": "^1.8.1", + "cors": "^2.8.6", + "debug": "^4.4.3", + "express": "^5.2.1", + "fs-promise": "^2.0.3", + "http-errors": "^2.0.1", + "method-override": "^3.0.0", + "morgan": "^1.10.1", + "pug": "^3.0.4", + "tar-stream": "^3.2.0", + "to-object-reducer": "^1.0.1" + }, + "bin": { + "dodo-transfer": "cli.js" + }, + "scripts": { + "start": "node app.js", + "dev": "node --watch --watch-path=lib --watch-path=public/pug --watch-path=lang app.js", + "debug": "node --inspect app.js", + "build": "npm --prefix app run build", + "test": "node --test \"tests/**/*.test.js\"", + "test:unit": "node --test \"tests/unit/*.test.js\"", + "test:integration": "node --test \"tests/integration/*.test.js\"" + }, + "engines": { + "node": ">= 24" + }, + "author": "Christoph Wiechert (PsiTransfer, the project this is based on)", + "license": "BSD-2-Clause" +} diff --git a/plugins/file-downloaded-webhook.js b/plugins/file-downloaded-webhook.js new file mode 100644 index 0000000..136e0a7 --- /dev/null +++ b/plugins/file-downloaded-webhook.js @@ -0,0 +1,23 @@ +const debug = require('debug')('psitransfer:plugin:file-downloaded-webook'); +const axios = require('axios'); + +module.exports = function setupFileDownloadedWebhook(eventBus, app, config, db) { + debug('Setup plugin'); + + if (!config.fileDownloadedWebhook) { + debug('No fileDownloadedWebhook configured. Plugin disabled.'); + return; + } + + function downloadWebook({ sid, file }) { + debug('Trigger: ' + config.fileDownloadedWebhook); + axios.post(config.fileDownloadedWebhook, { + fid: sid, + file, + date: Date.now() + }).catch(err => console.error(err)); + } + + eventBus.on('archiveDownloaded', downloadWebook); + eventBus.on('fileDownloaded', downloadWebook); +} diff --git a/plugins/file-uploaded-webhook.js b/plugins/file-uploaded-webhook.js new file mode 100644 index 0000000..d41b7c7 --- /dev/null +++ b/plugins/file-uploaded-webhook.js @@ -0,0 +1,25 @@ +const debug = require('debug')('psitransfer:plugin:file-uploaded-webook'); +const axios = require('axios'); + +module.exports = function setupFileUploadedWebhook(eventBus, app, config, db) { + debug('Setup plugin'); + if (!config.fileUploadedWebhook) { + debug('No fileUploadedWebhook configured. Plugin disabled.'); + return; + } + + function downloadWebook({ metadata }) { + debug('Trigger: ' + config.fileUploadedWebhook); + const safeMetadata = metadata ? { ...metadata } : metadata; + if (safeMetadata && safeMetadata.password) { + safeMetadata._password = true; + delete safeMetadata.password; + } + axios.post(config.fileUploadedWebhook, { + metadata: safeMetadata, + date: Date.now() + }).catch(err => console.error(err)); + } + + eventBus.on('fileUploaded', downloadWebook); +} diff --git a/public/assets/logo.svg b/public/assets/logo.svg new file mode 100644 index 0000000..0df8944 --- /dev/null +++ b/public/assets/logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/pug/error.pug b/public/pug/error.pug new file mode 100644 index 0000000..4b5f49a --- /dev/null +++ b/public/pug/error.pug @@ -0,0 +1,30 @@ +doctype html +html(lang=lang.langCode || 'ko') + head + base(href=baseUrl) + meta(charset='utf-8') + title= brand.name + link(href=brand.logoUrl rel='icon') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='robots' content='noindex,nofollow') + link(href='app/main.css' rel='stylesheet') + include partials/head_custom + body + .view.on.error-view + header.hdr + .in + .brand + img(src=brand.logoUrl alt='') + span.bn= brand.name + .scroll + .wrap + .card.center-card + .big.danger + svg.ic(width='26' height='26' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' aria-hidden='true') + path(d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3') + path(d='M12 9v4') + path(d='M12 17h.01') + h2= lang.errorTitle + p.muted= error + a.btn.primary(href=uploadAppPath) #{lang.newUpload} + footer.foot= brand.footer diff --git a/public/pug/page.pug b/public/pug/page.pug new file mode 100644 index 0000000..3374a55 --- /dev/null +++ b/public/pug/page.pug @@ -0,0 +1,17 @@ +doctype html +html(lang=lang.langCode || 'ko') + head + base(href=baseUrl) + meta(charset='utf-8') + title= brand.name + link(href=brand.logoUrl rel='icon') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='robots' content='noindex,nofollow') + link(href='app/main.css' rel='stylesheet') + include partials/head_custom + body(data-page=page) + #app + noscript= lang.noScript + script. + window.PSITRANSFER = { uploadPath: '#{uploadAppPath}', brand: !{brandJson} }; + script(type='module' src='app/main.js') diff --git a/public/pug/partials/head_custom.pug b/public/pug/partials/head_custom.pug new file mode 100644 index 0000000..e69de29 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/scripts/create-bundle.sh b/scripts/create-bundle.sh new file mode 100644 index 0000000..889a273 --- /dev/null +++ b/scripts/create-bundle.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -e +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd $DIR + +# Get the current tag or use commit hash if there's none +COMMIT=$(git log -n1 --pretty='%h' 2>/dev/null || echo current) +NAME=$(git describe --exact-match --tags $COMMIT 2>/dev/null || echo $COMMIT) + + +echo "### Building frontend apps" +echo "======================================================" +cd $DIR/../app +npm ci +npm run build + + +echo +echo "### Bundling to _releases/dodo-transfer-$NAME.tar.gz" +echo "======================================================" +cd $DIR/.. +mkdir -p _releases + +tar -czf _releases/dodo-transfer-$NAME.tar.gz --transform "s~^~dodo-transfer-$NAME/~" \ + LICENSE \ + README.md \ + Dockerfile \ + .dockerignore \ + app.js \ + cli.js \ + config.js \ + package.json \ + package-lock.json \ + docs \ + lib \ + lang \ + plugins \ + public + + +cd $DIR/.. +#if [ -d .git ]; then +# LAST_TAG=$(git tag | head -n 2 | tail -n 1) +# echo +# echo "### Changelog $LAST_TAG..HEAD" +# echo "======================================================" +# [ -z "$LAST_TAG" ] && git log --oneline || git log $LAST_TAG..HEAD --oneline +#fi diff --git a/scripts/traffic-limit.sh b/scripts/traffic-limit.sh new file mode 100644 index 0000000..630750c --- /dev/null +++ b/scripts/traffic-limit.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +# Helper script to limit traffic for iface:port +# http://www.codeoriented.com/how-to-limit-network-bandwidth-for-a-specific-tcp-port-on-ubuntu-linux/ +# https://wiki.archlinux.org/index.php/Advanced_traffic_control + +if ! which tc &>/dev/null ; then + 2>&1 echo Error: tc executable not found + 2>&1 echo Please install iproute2 package + exit 1 +fi + +IFACE=$1 +RATE=$3 +PORT=$2 + +if [ -z "$3" ] ; then + echo "Traffic limitter" + echo "Usage: $0 IFACE PORT RATE" + echo + echo "Available interfaces: $(ls -m /sys/class/net)" + echo + echo "Rate units: " + echo " kbps: Kilobytes per Second" + echo " mbps: Megabytes per Second" + echo " gbps: Gigabytes per Second" + echo " off: No rate limit" + echo + echo "Examples:" + echo " $0 wlan0 8080 10kbps" + echo " $0 wlan0 8080 off" + exit 1 +fi + +sudo tc qdisc del root dev $IFACE &>/dev/null || true + +[ "$RATE" = "off" ] && exit + +sudo tc qdisc add dev $IFACE root handle 1:0 htb default 10 +sudo tc class add dev $IFACE parent 1:0 classid 1:10 htb rate $RATE prio 0 +sudo tc filter add dev $IFACE parent 1:0 prio 0 protocol ip u32 match ip protocol 4 0xff match ip dport $PORT 0xffff flowid 1:10 +sudo tc qdisc show dev $IFACE diff --git a/tests/integration/features.test.js b/tests/integration/features.test.js new file mode 100644 index 0000000..00d8c9c --- /dev/null +++ b/tests/integration/features.test.js @@ -0,0 +1,483 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('node:http'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-features-')); +process.env.PSITRANSFER_UPLOAD_DIR = tmpDir; +process.env.PSITRANSFER_UPLOAD_PASS = 'up-secret'; +process.env.PSITRANSFER_ADMIN_PASS = 'ad-secret'; +process.env.PSITRANSFER_ACCESS_LOG = ''; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); + +const tusMeta = require('../../lib/tusboy/tus-metadata'); +const app = require('../../lib/endpoints'); + +const TUS = { 'Tus-Resumable': '1.0.0' }; +const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(64)]); +const ADMIN = { 'x-passwd': 'ad-secret' }; + +let server; +let port; +let token; + +function request(method, pathname, headers = {}, body = null) { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port, method, path: pathname, headers, agent: false }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const buf = Buffer.concat(chunks); + let json; + try { json = JSON.parse(buf.toString()); } catch (e) { /* not json */ } + resolve({ status: res.statusCode, headers: res.headers, body: buf, text: buf.toString(), json }); + }); + } + ); + req.on('error', reject); + if (body != null) req.write(body); + req.end(); + }); +} + +const bearer = () => ({ Authorization: `Bearer ${ token }` }); +const jsonBody = (obj) => [{ 'Content-Type': 'application/json' }, JSON.stringify(obj)]; + +/** Upload one file via Tus, returns { fid, key, created } or the failed creation response */ +async function upload({ sid, name = 'file.txt', content = 'hello', retention = '3600', password, internalBypass, headers = bearer() }) { + const meta = { name, sid, retention }; + if (password) meta.password = password; + if (internalBypass) meta.internalBypass = internalBypass; + const created = await request('POST', '/files/', { + ...TUS, ...headers, + 'Upload-Length': String(Buffer.byteLength(content)), + 'Upload-Metadata': tusMeta.encode(meta), + }); + if (created.status !== 201) return { created }; + const fid = decodeURIComponent(created.headers.location.split('/').pop()); + const patched = await request('PATCH', `/files/${ encodeURIComponent(fid) }`, { + ...TUS, ...headers, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }, content); + assert.strictEqual(patched.status, 204); + return { fid, key: fid.split('++')[1], created }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 100)); + +before(async () => { + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; + const login = await request('POST', '/login', ...jsonBody({ password: 'up-secret' })); + assert.strictEqual(login.status, 200); + token = login.json.token; +}); + +after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ---------------------------------------------------------------- login + +test('login token: wrong password, right password, token reuse', async () => { + const wrong = await request('POST', '/login', ...jsonBody({ password: 'nope' })); + assert.strictEqual(wrong.status, 403); + + assert.ok(token && token.includes('.')); + + assert.strictEqual((await request('GET', '/config.json')).status, 401); + assert.strictEqual((await request('GET', '/config.json', { Authorization: 'Bearer garbage' })).status, 403); + + const withToken = await request('GET', '/config.json', bearer()); + assert.strictEqual(withToken.status, 200); + assert.ok(withToken.json.session.token, 'the token is renewed on use'); + assert.ok(withToken.json.session.exp > Date.now() + 6 * 24 * 3600 * 1000, 'valid for about 7 days'); + assert.deepStrictEqual(withToken.json.quickRetentions, ['one-time', '86400', '259200', '432000', '604800']); + assert.strictEqual(withToken.json.authRequired, true); +}); + +test('legacy x-passwd header still works for scripts', async () => { + const res = await request('GET', '/config.json', { 'x-passwd': 'up-secret' }); + assert.strictEqual(res.status, 200); + const up = await upload({ sid: 'legacyheader', headers: { 'x-passwd': 'up-secret' }, password: 'pw' }); + assert.ok(up.fid); +}); + +test('uploading needs a login', async () => { + const meta = tusMeta.encode({ name: 'x', sid: 'nologin', retention: '3600', password: 'p' }); + const res = await request('POST', '/files/', { ...TUS, 'Upload-Length': '1', 'Upload-Metadata': meta }); + assert.strictEqual(res.status, 401); +}); + +// ------------------------------------------------------- password rules + +test('a password is required except for one-time downloads', async () => { + const noPw = await upload({ sid: 'needpw', retention: '3600' }); + assert.strictEqual(noPw.created.status, 400); + assert.match(noPw.created.json.message, /password/i); + + const oneTime = await upload({ sid: 'needpw1', retention: 'one-time' }); + assert.ok(oneTime.fid, 'one-time may go without password'); + + const withPw = await upload({ sid: 'needpw2', retention: '3600', password: 'pw' }); + assert.ok(withPw.fid); +}); + +test('metadata the server owns can not be forged by the client', async () => { + const up = await upload({ sid: 'forgery', password: 'pw' }); + const meta = tusMeta.encode({ name: 'a.txt', sid: 'forgery', retention: '3600', password: 'pw', thumb: 'text/html', lastDownload: '1', bucketLocked: 'true' }); + const res = await request('POST', '/files/', { ...TUS, ...bearer(), 'Upload-Length': '1', 'Upload-Metadata': meta }); + assert.strictEqual(res.status, 201); + const list = await request('GET', '/forgery.json', { 'x-download-pass': 'pw' }); + const forged = list.json.items.find(i => i.key !== up.key); + // unfinished uploads are not listed + assert.strictEqual(forged, undefined); +}); + +// -------------------------------------------------- listing and bypass + +test('download list: password, wrong password, no password leak', async () => { + const { key } = await upload({ sid: 'plainlist', password: 'secret1', name: 'doc.txt' }); + assert.strictEqual((await request('GET', '/plainlist.json')).status, 401); + assert.strictEqual((await request('GET', '/plainlist.json', { 'x-download-pass': 'bad' })).status, 401); + // a login alone does not skip a password unless the uploader allowed it + assert.strictEqual((await request('GET', '/plainlist.json', bearer())).status, 401); + + const ok = await request('GET', '/plainlist.json', { 'x-download-pass': 'secret1' }); + assert.strictEqual(ok.status, 200); + assert.strictEqual(ok.json.bypassed, false); + const item = ok.json.items[0]; + assert.strictEqual(item.key, key); + assert.strictEqual(item.metadata.hasPassword, true); + assert.strictEqual(item.metadata.password, undefined, 'the hash is not sent'); + assert.strictEqual(ok.json.session, null, 'no login, no token'); + assert.ok(ok.json.expiresAt > Date.now()); + assert.strictEqual(item.expiresAt, ok.json.expiresAt); +}); + +test('logged in users skip the password when the uploader allowed it', async () => { + await upload({ sid: 'bypassed', password: 'secret2', internalBypass: 'true' }); + assert.strictEqual((await request('GET', '/bypassed.json')).status, 401); + assert.strictEqual((await request('GET', '/bypassed.json', { Authorization: 'Bearer garbage' })).status, 401); + + const viaLogin = await request('GET', '/bypassed.json', bearer()); + assert.strictEqual(viaLogin.status, 200); + assert.strictEqual(viaLogin.json.bypassed, true); + assert.ok(viaLogin.json.session.token, 'using the download page renews the login'); + + // everybody else still needs the password + const viaPass = await request('GET', '/bypassed.json', { 'x-download-pass': 'secret2' }); + assert.strictEqual(viaPass.status, 200); + assert.strictEqual(viaPass.json.bypassed, false); +}); + +test('the bypass flag is ignored without a password', async () => { + const { fid } = await upload({ sid: 'bypassnopw', retention: 'one-time', internalBypass: 'true' }); + const info = JSON.parse(fs.readFileSync(path.join(tmpDir, ...fid.split('++')) + '.json', 'utf8')); + assert.strictEqual(info.metadata.internalBypass, undefined); + assert.strictEqual(info.metadata.password, undefined); +}); + +test('archive of a bucket needs the token from the list', async () => { + await upload({ sid: 'arch1', password: 'pw', name: 'a.txt', content: 'AAA' }); + await upload({ sid: 'arch1', password: 'pw', name: 'b.txt', content: 'BBB' }); + const list = await request('GET', '/arch1.json', { 'x-download-pass': 'pw' }); + const res = await request('GET', `/files/arch1++${ list.json.archiveToken }.zip`); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers['content-type'], 'application/zip'); + assert.strictEqual(res.body.subarray(0, 2).toString(), 'PK'); + const bad = await request('GET', '/files/arch1++' + '0'.repeat(32) + '.zip'); + assert.strictEqual(bad.status, 404); +}); + +// -------------------------------------------------------------- preview + +test('inline preview: type from the file name, not a download, no one-time', async () => { + const { fid, key } = await upload({ sid: 'preview1', password: 'pw', name: 'note.txt', content: 'hi' }); + + const res = await request('GET', `/files/${ fid }?inline=1`); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8'); + assert.strictEqual(res.headers['content-disposition'], 'inline'); + assert.strictEqual(res.headers['x-content-type-options'], 'nosniff'); + assert.match(res.headers['content-security-policy'], /sandbox/); + await tick(); + + let list = await request('GET', '/preview1.json', { 'x-download-pass': 'pw' }); + assert.strictEqual(list.json.items[0].metadata.lastDownload, undefined, 'a preview is not a download'); + assert.strictEqual(list.json.items[0].previewType, 'text'); + + const dl = await request('GET', `/files/${ fid }`); + assert.strictEqual(dl.status, 200); + assert.match(dl.headers['content-disposition'], /^attachment; filename="note.txt"/); + await tick(); + list = await request('GET', '/preview1.json', { 'x-download-pass': 'pw' }); + assert.ok(list.json.items.find(i => i.key === key).metadata.lastDownload, 'a download is counted'); +}); + +test('inline preview: html and unknown types are refused, pdf has no sandbox', async () => { + const html = await upload({ sid: 'preview2', password: 'pw', name: 'page.html', content: '' }); + assert.strictEqual((await request('GET', `/files/${ html.fid }?inline=1`)).status, 404); + const bin = await upload({ sid: 'preview2', password: 'pw', name: 'data.bin' }); + assert.strictEqual((await request('GET', `/files/${ bin.fid }?inline=1`)).status, 404); + + const pdf = await upload({ sid: 'preview2', password: 'pw', name: 'doc.pdf', content: '%PDF-1.4' }); + const res = await request('GET', `/files/${ pdf.fid }?inline=1`); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers['content-type'], 'application/pdf'); + assert.strictEqual(res.headers['content-security-policy'], undefined); +}); + +test('inline preview never uses up a one-time file', async () => { + const { fid } = await upload({ sid: 'preview3', name: 'once.txt', retention: 'one-time', content: 'secret' }); + assert.strictEqual((await request('GET', `/files/${ fid }?inline=1`)).status, 404); + const dl = await request('GET', `/files/${ fid }`); + assert.strictEqual(dl.status, 200); + assert.strictEqual(dl.text, 'secret'); +}); + +// ------------------------------------------------------------ thumbnails + +test('thumbnails: upload, serve, delete with the file', async () => { + const { fid, key } = await upload({ sid: 'thumbs1', password: 'pw', name: 'pic.png', content: 'x' }); + + assert.strictEqual((await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, {}, PNG)).status, 401); + const bad = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), Buffer.from('not an image')); + assert.strictEqual(bad.status, 400); + + const ok = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, { ...bearer(), 'Content-Type': 'image/png' }, PNG); + assert.strictEqual(ok.status, 204); + + const list = await request('GET', '/thumbs1.json', { 'x-download-pass': 'pw' }); + const item = list.json.items[0]; + assert.match(item.thumbUrl, /files\/thumbs1\+\+.+\.thumb$/); + + const img = await request('GET', item.thumbUrl.replace(/^\//, '/')); + assert.strictEqual(img.status, 200); + assert.strictEqual(img.headers['content-type'], 'image/png'); + assert.strictEqual(img.headers['x-content-type-options'], 'nosniff'); + assert.ok(img.body.equals(PNG)); + + const thumbFile = path.join(tmpDir, 'thumbs1', key) + '.thumb'; + assert.ok(fs.existsSync(thumbFile)); + + // too large + const big = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), Buffer.concat([PNG, Buffer.alloc(70 * 1024)])); + assert.strictEqual(big.status, 413); + + // locked buckets take no more thumbnails + assert.strictEqual((await request('PATCH', '/files/thumbs1?lock=yes', { ...TUS, ...bearer() })).status, 204); + const locked = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), PNG); + assert.strictEqual(locked.status, 400); + + // deleting the file deletes the thumbnail + const del = await request('DELETE', `/admin/buckets/thumbs1/files/${ key }`, ADMIN); + assert.strictEqual(del.status, 200); + assert.ok(!fs.existsSync(thumbFile)); + assert.strictEqual((await request('GET', item.thumbUrl)).status, 404); +}); + +test('one-time files show no thumbnail', async () => { + const { fid } = await upload({ sid: 'thumbs2', name: 'pic.png', retention: 'one-time' }); + const put = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), PNG); + assert.strictEqual(put.status, 204); + const list = await request('GET', '/thumbs2.json'); + assert.strictEqual(list.json.items[0].thumbUrl, null); + assert.strictEqual((await request('GET', `/files/${ fid }.thumb`)).status, 404); + assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 200); +}); + +// ----------------------------------------------------------------- admin + +test('admin area needs the admin password', async () => { + assert.strictEqual((await request('GET', '/admin/data.json')).status, 401); + assert.strictEqual((await request('GET', '/admin/data.json', { 'x-passwd': 'up-secret' })).status, 403); + assert.strictEqual((await request('GET', '/admin/data.json', bearer())).status, 403, 'a login token is no admin'); + const ok = await request('GET', '/admin/data.json', ADMIN); + assert.strictEqual(ok.status, 200); + assert.ok(Array.isArray(ok.json.buckets)); + assert.strictEqual((await request('GET', '/admin')).status, 200); +}); + +test('admin sees everything and removes single files', async () => { + const a = await upload({ sid: 'admin1', password: 'pw', name: 'a.txt', content: 'AAA' }); + const b = await upload({ sid: 'admin1', password: 'pw', name: 'b.txt', content: 'BBB' }); + const data = await request('GET', '/admin/data.json', ADMIN); + const bucket = data.json.buckets.find(x => x.sid === 'admin1'); + assert.strictEqual(bucket.files.length, 2); + assert.strictEqual(bucket.files[0].hasPassword, true); + assert.ok(bucket.files[0].url, 'urls of password buckets are not hidden'); + assert.strictEqual(bucket.files[0].password, undefined); + + assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`)).status, 401); + assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/not-a-uuid`, ADMIN)).status, 400); + assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`, ADMIN)).status, 200); + assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`, ADMIN)).status, 404); + + // recipients see the remaining file only + const list = await request('GET', '/admin1.json', { 'x-download-pass': 'pw' }); + assert.deepStrictEqual(list.json.items.map(i => i.key), [b.key]); + assert.strictEqual((await request('GET', `/files/${ a.fid }`)).status, 404); + + // the last file takes the bucket with it + assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ b.key }`, ADMIN)).status, 200); + assert.strictEqual((await request('GET', '/admin1.json')).status, 404); +}); + +test('admin removes a whole bucket', async () => { + await upload({ sid: 'admin2', password: 'pw', name: 'a.txt' }); + await upload({ sid: 'admin2', password: 'pw', name: 'b.txt' }); + const res = await request('DELETE', '/admin/buckets/admin2', ADMIN); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.removed, 2); + assert.strictEqual((await request('GET', '/admin2.json')).status, 404); + assert.strictEqual((await request('DELETE', '/admin/buckets/admin2', ADMIN)).status, 404); + assert.strictEqual(fs.existsSync(path.join(tmpDir, 'admin2')), false); +}); + +test('admin download uses a short lived link and keeps one-time files', async () => { + const { key } = await upload({ sid: 'admin3', name: 'once.txt', retention: 'one-time', content: 'only once' }); + + const noAuth = await request('POST', '/admin/download-link', ...jsonBody({ sid: 'admin3', key })); + assert.strictEqual(noAuth.status, 401); + + const link = await request('POST', '/admin/download-link', { ...ADMIN, ...jsonBody({ sid: 'admin3', key })[0] }, jsonBody({ sid: 'admin3', key })[1]); + assert.strictEqual(link.status, 200); + const res = await request('GET', link.json.url); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.text, 'only once'); + assert.match(res.headers['content-disposition'], /^attachment; filename="once.txt"/); + await tick(); + + // twice is fine and the recipient can still get the file once + assert.strictEqual((await request('GET', link.json.url)).status, 200); + assert.strictEqual((await request('GET', `/files/admin3++${ key }`)).status, 200); + await tick(); + assert.strictEqual((await request('GET', `/files/admin3++${ key }`)).status, 404); + + assert.strictEqual((await request('GET', '/admin/dl?t=garbage')).status, 403); + // a login token can not be used as download link + assert.strictEqual((await request('GET', `/admin/dl?t=${ encodeURIComponent(token) }`)).status, 403); +}); + +test('admin archive download of a bucket', async () => { + await upload({ sid: 'admin4', password: 'pw', name: 'a.txt', content: 'AAA' }); + const link = await request('POST', '/admin/download-link', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ sid: 'admin4', format: 'tar.gz' })); + assert.strictEqual(link.status, 200); + const res = await request('GET', link.json.url); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body[0], 0x1f); + assert.strictEqual(res.body[1], 0x8b); +}); + +// -------------------------------------------------------------- settings + +test('admin settings change the brand for everybody', async () => { + const before = await request('GET', '/brand.json'); + assert.strictEqual(before.json.name, 'Dodo Transfer'); + + assert.strictEqual((await request('PUT', '/admin/settings', { 'Content-Type': 'application/json' }, '{"name":"x"}')).status, 401); + const bad = await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, '{"name":""}'); + assert.strictEqual(bad.status, 400); + + const ok = await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, + JSON.stringify({ name: 'Local Transfer', owner: 'Dev', from: 2025 })); + assert.strictEqual(ok.status, 200); + + const brand = await request('GET', '/brand.json'); + assert.strictEqual(brand.json.name, 'Local Transfer'); + assert.match(brand.json.footer, /^© 2025(–\d{4})? Dev$/); + + // the shell page carries the new name + const page = await request('GET', '/'); + assert.match(page.text, /Local Transfer<\/title>/); + assert.ok(!page.text.includes('Dodo Transfer')); + + // names are escaped + await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ name: '</script><b>x' })); + const evil = await request('GET', '/'); + assert.ok(!evil.text.includes('</script><b>')); + await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ name: 'Dodo Transfer', owner: '아진산업(주) 전장선행개발팀', from: 2026 })); +}); + +test('admin logo upload, serving and reset', async () => { + const defaultLogo = await request('GET', '/assets/brand-logo'); + assert.strictEqual(defaultLogo.status, 200); + assert.strictEqual(defaultLogo.headers['content-type'], 'image/svg+xml'); + + const text = await request('POST', '/admin/settings/logo', ADMIN, Buffer.from('not an image')); + assert.strictEqual(text.status, 400); + assert.strictEqual((await request('POST', '/admin/settings/logo', {}, PNG)).status, 401); + + const big = await request('POST', '/admin/settings/logo', ADMIN, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)])); + assert.strictEqual(big.status, 413); + + const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>'); + const ok = await request('POST', '/admin/settings/logo', ADMIN, svg); + assert.strictEqual(ok.status, 200); + assert.strictEqual(ok.json.brand.customLogo, true); + const served = await request('GET', ok.json.brand.logoUrl); + assert.strictEqual(served.headers['content-type'], 'image/svg+xml'); + assert.match(served.headers['content-security-policy'], /sandbox/); + assert.ok(served.body.equals(svg)); + + const reset = await request('DELETE', '/admin/settings/logo', ADMIN); + assert.strictEqual(reset.json.brand.customLogo, false); + assert.strictEqual((await request('GET', '/assets/brand-logo')).headers['content-type'], 'image/svg+xml'); +}); + +// ---------------------------------------------------------------- misc + +test('expiry is the earlier of retention and maxAge', async () => { + const db = require('../../lib/db'); + const config = require('../../config'); + const probe = new db(tmpDir, null); + const created = Date.now(); + const file = (retention) => ({ metadata: { createdAt: String(created), retention } }); + assert.strictEqual(probe.expiresAt(file('3600')), created + 3600_000); + assert.strictEqual(probe.expiresAt(file('one-time')), created + config.maxAge * 1000); + assert.strictEqual(probe.expiresAt(file(String(config.maxAge * 2))), created + config.maxAge * 1000); +}); + +test('the old misspelled lock flag is still honored', async () => { + const { fid } = await upload({ sid: 'oldlock', password: 'pw' }); + const jsonFile = path.join(tmpDir, ...fid.split('++')) + '.json'; + const info = JSON.parse(fs.readFileSync(jsonFile, 'utf8')); + info.metadata.buckedLocked = true; + fs.writeFileSync(jsonFile, JSON.stringify(info)); + const db = require('../../lib/db'); + const fresh = new db(tmpDir, new (require('../../lib/store'))(tmpDir)); + fresh.init(); + assert.strictEqual(fresh.isLocked('oldlock'), true); +}); + +test('hidden folders in the data dir are no buckets', async () => { + fs.mkdirSync(path.join(tmpDir, '.settings'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.settings', 'settings.json'), '{}'); + const db = require('../../lib/db'); + const fresh = new db(tmpDir, new (require('../../lib/store'))(tmpDir)); + fresh.init(); + assert.strictEqual(fresh.get('.settings'), undefined); +}); + +test('health check answers without a login', async () => { + const res = await request('GET', '/healthz'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.text, 'ok'); +}); + +test('error pages are branded and escaped', async () => { + const res = await request('GET', '/does-not-exist'); + assert.strictEqual(res.status, 404); + assert.match(res.text, /<title>Dodo Transfer<\/title>/); +}); diff --git a/tests/integration/middleware.test.js b/tests/integration/middleware.test.js new file mode 100644 index 0000000..ea40d8a --- /dev/null +++ b/tests/integration/middleware.test.js @@ -0,0 +1,170 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('node:http'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-int-')); +process.env.PSITRANSFER_UPLOAD_DIR = tmpDir; +// these tests upload without a password +process.env.PSITRANSFER_REQUIRE_BUCKET_PASSWORD = 'false'; + +const { test, after } = require('node:test'); +const assert = require('node:assert'); + +const tusMeta = require('../../lib/tusboy/tus-metadata'); +const app = require('../../lib/endpoints'); + +const TUS = { 'Tus-Resumable': '1.0.0' }; +const UUID = '00000000-0000-4000-8000-000000000001'; + +function request(port, method, pathname, headers = {}, body = null) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + method, + path: pathname, + headers, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + } + ); + req.on('error', reject); + if (body != null) req.write(body); + req.end(); + }); +} + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('store.info error that is not NotFound yields 500 (instanceof guard)', async (t) => { + const sid = 'corruptsid'; + const bucketDir = path.join(tmpDir, sid); + const fileBase = path.join(bucketDir, UUID); + fs.mkdirSync(bucketDir, { recursive: true }); + fs.writeFileSync(`${fileBase}.json`, '{ not json', 'utf8'); + fs.writeFileSync(fileBase, '', 'utf8'); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const fid = `${sid}++${UUID}`; + const res = await request(port, 'PATCH', `/files/${encodeURIComponent(fid)}`, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }); + assert.strictEqual(res.status, 500); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(bucketDir, { recursive: true, force: true }); + } +}); + +test('PATCH with encoded traversal in upload id is rejected before Tus handler', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const pathname = + '/files/foo%2B%2B' + encodeURIComponent('../..') + '%2Fetc%2Fpasswd'; + const res = await request(port, 'PATCH', pathname, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }); + assert.ok(res.status === 400 || res.status === 404); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('POST with malicious meta.sid is rejected', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const meta = { + name: 'x', + sid: '../../etc', + retention: '3600', + }; + const res = await request(port, 'POST', '/files/', { + ...TUS, + 'Upload-Length': '10', + 'Upload-Metadata': tusMeta.encode(meta), + 'Content-Type': 'application/offset+octet-stream', + }); + assert.strictEqual(res.status, 400); + assert.match(res.body, /bucket|Invalid/i); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('lock PATCH with traversal bucket id is rejected', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const res = await request( + port, + 'PATCH', + '/files/' + encodeURIComponent('..') + '%2F' + encodeURIComponent('..') + '%2Fetc?lock=yes', + TUS + ); + assert.strictEqual(res.status, 400); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('PATCH on a running upload of a locked bucket is rejected', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const sid = 'lockedbucket'; + const created = await request(port, 'POST', '/files/', { + ...TUS, + 'Upload-Length': '10', + 'Upload-Metadata': tusMeta.encode({ name: 'x', sid, retention: '3600' }), + }); + assert.strictEqual(created.status, 201); + const fid = decodeURIComponent(created.headers.location.split('/').pop()); + + const lock = await request(port, 'PATCH', `/files/${sid}?lock=yes`, TUS); + assert.strictEqual(lock.status, 204); + + const res = await request(port, 'PATCH', `/files/${encodeURIComponent(fid)}`, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }, '0123456789'); + assert.strictEqual(res.status, 400); + assert.match(res.body, /Bucket locked/); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(path.join(tmpDir, 'lockedbucket'), { recursive: true, force: true }); + } +}); diff --git a/tests/integration/one-time-download.test.js b/tests/integration/one-time-download.test.js new file mode 100644 index 0000000..fe9fff5 --- /dev/null +++ b/tests/integration/one-time-download.test.js @@ -0,0 +1,195 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('node:http'); +const { createHash } = require('node:crypto'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-onetime-')); +process.env.PSITRANSFER_UPLOAD_DIR = tmpDir; +// these tests upload without a password +process.env.PSITRANSFER_REQUIRE_BUCKET_PASSWORD = 'false'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); + +const tusMeta = require('../../lib/tusboy/tus-metadata'); +const app = require('../../lib/endpoints'); + +const TUS = { 'Tus-Resumable': '1.0.0' }; +const PARALLEL = 10; + +let server; +let port; + +function request(method, pathname, headers = {}, body = null) { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port, method, path: pathname, headers, agent: false }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }); + }); + } + ); + req.on('error', reject); + if (body != null) req.write(body); + req.end(); + }); +} + +// Upload a file via Tus and return its fid (sid++key) +async function upload(sid, content, retention = 'one-time') { + const meta = { name: 'file.bin', sid, retention }; + const created = await request('POST', '/files/', { + ...TUS, + 'Upload-Length': String(content.length), + 'Upload-Metadata': tusMeta.encode(meta), + }); + assert.strictEqual(created.status, 201); + const fid = decodeURIComponent(created.headers.location.split('/').pop()); + const patched = await request('PATCH', `/files/${ encodeURIComponent(fid) }`, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }, content); + assert.strictEqual(patched.status, 204); + return fid; +} + +function archiveUrl(sid, fids, format = 'zip') { + const keyList = fids.map(fid => fid.split('++')[1]).join(); + const hash = createHash('sha256').update(keyList).digest('hex').slice(0, 32); + return `/files/${ sid }++${ hash }.${ format }`; +} + +// Wait until the server-side finish/close handlers have run +const tick = () => new Promise(resolve => setTimeout(resolve, 100)); + +before(async () => { + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); + +after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('parallel downloads of a one-time file deliver it exactly once', async () => { + const content = Buffer.alloc(1024 * 1024, 'a'); + const fid = await upload('onetimeparallel', content); + + const results = await Promise.all( + Array.from({ length: PARALLEL }, () => request('GET', `/files/${ fid }`)) + ); + const ok = results.filter(r => r.status === 200); + assert.strictEqual(ok.length, 1); + assert.ok(ok[0].body.equals(content)); + assert.strictEqual(results.filter(r => r.status === 404).length, PARALLEL - 1); + + await tick(); + assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404); + // server is still alive + assert.strictEqual((await request('GET', '/robots.txt')).status, 200); +}); + +test('parallel archive downloads of a one-time bucket deliver it exactly once', async () => { + const sid = 'onetimearchive'; + const fids = [ + await upload(sid, Buffer.alloc(512 * 1024, 'a')), + await upload(sid, Buffer.alloc(512 * 1024, 'b')), + ]; + const url = archiveUrl(sid, fids); + + const results = await Promise.all( + Array.from({ length: PARALLEL }, () => request('GET', url)) + ); + assert.strictEqual(results.filter(r => r.status === 200).length, 1); + assert.strictEqual(results.filter(r => r.status === 404).length, PARALLEL - 1); + + await tick(); + for (const fid of fids) { + assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404); + } + assert.strictEqual((await request('GET', '/robots.txt')).status, 200); +}); + +test('archive download is refused while a one-time file is being downloaded', async () => { + const sid = 'onetimemixed'; + const fids = [ + await upload(sid, Buffer.alloc(32 * 1024 * 1024, 'a')), + await upload(sid, Buffer.from('permanent'), '3600'), + ]; + + // start a single file download and keep it open + const pending = new Promise((resolve, reject) => { + const req = http.get({ hostname: '127.0.0.1', port, path: `/files/${ fids[0] }`, agent: false }, (res) => { + res.pause(); + resolve({ req, res }); + }); + req.on('error', reject); + }); + const { req, res } = await pending; + assert.strictEqual(res.statusCode, 200); + + assert.strictEqual((await request('GET', archiveUrl(sid, fids))).status, 404); + // non one-time files are not affected + assert.strictEqual((await request('GET', `/files/${ fids[1] }`)).status, 200); + + req.destroy(); + await tick(); +}); + +test('aborted download releases the one-time file', async () => { + const content = Buffer.alloc(32 * 1024 * 1024, 'a'); + const fid = await upload('onetimeabort', content); + + await new Promise((resolve, reject) => { + const req = http.get({ hostname: '127.0.0.1', port, path: `/files/${ fid }`, agent: false }, (res) => { + assert.strictEqual(res.statusCode, 200); + res.once('data', () => { + req.destroy(); + resolve(); + }); + }); + req.on('error', (e) => e.code === 'ECONNRESET' ? resolve() : reject(e)); + }); + await tick(); + + const res = await request('GET', `/files/${ fid }`); + assert.strictEqual(res.status, 200); + assert.ok(res.body.equals(content)); +}); + +test('HEAD does not consume a one-time file', async () => { + const content = Buffer.from('head request'); + const fid = await upload('onetimehead', content); + + const head = await request('HEAD', `/files/${ fid }`); + assert.strictEqual(head.status, 200); + await tick(); + + const res = await request('GET', `/files/${ fid }`); + assert.strictEqual(res.status, 200); + assert.ok(res.body.equals(content)); + + await tick(); + assert.strictEqual((await request('HEAD', `/files/${ fid }`)).status, 404); +}); + +test('completed range request consumes a one-time file', async () => { + const fid = await upload('onetimerange', Buffer.from('0123456789abcdef')); + + const res = await request('GET', `/files/${ fid }`, { Range: 'bytes=0-9' }); + assert.strictEqual(res.status, 206); + assert.strictEqual(res.body.toString(), '0123456789'); + + await tick(); + assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404); +}); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js new file mode 100644 index 0000000..47ad429 --- /dev/null +++ b/tests/unit/auth.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const config = require('../../config'); +const auth = require('../../lib/auth'); + +function withPasswords(upload, admin, fn) { + const before = { uploadPass: config.uploadPass, adminPass: config.adminPass, tokenSecret: config.tokenSecret }; + Object.assign(config, { uploadPass: upload, adminPass: admin, tokenSecret: '' }); + try { + return fn(); + } finally { + Object.assign(config, before); + } +} + +test('issued token verifies until it expires', () => withPasswords('up', 'ad', () => { + const now = 1_000_000; + const { token, exp } = auth.issue({}, 60, 'session', now); + assert.strictEqual(exp, now + 60_000); + assert.ok(auth.verify(token, 'session', now + 59_000)); + assert.strictEqual(auth.verify(token, 'session', now + 60_000), null); +})); + +test('tampered token is rejected', () => withPasswords('up', 'ad', () => { + const { token } = auth.issue({}, 60); + const [payload, sig] = token.split('.'); + const forged = Buffer.from(JSON.stringify({ exp: Date.now() + 1e12 })).toString('base64url'); + assert.strictEqual(auth.verify(`${ forged }.${ sig }`), null); + assert.strictEqual(auth.verify(`${ payload }.${ sig.slice(0, -2) }xx`), null); + assert.strictEqual(auth.verify('garbage'), null); + assert.strictEqual(auth.verify(''), null); + assert.strictEqual(auth.verify(undefined), null); +})); + +test('a token is only valid for its purpose', () => withPasswords('up', 'ad', () => { + const { token } = auth.issue({ sid: 'x' }, 60, 'admin-download'); + assert.strictEqual(auth.verify(token, 'session'), null); + assert.strictEqual(auth.verify(token, 'admin-download').sid, 'x'); +})); + +test('changing the upload or the admin password invalidates tokens', () => { + const { token } = withPasswords('up', 'ad', () => auth.issue({}, 60)); + assert.ok(withPasswords('up', 'ad', () => auth.verify(token))); + assert.strictEqual(withPasswords('other', 'ad', () => auth.verify(token)), null); + assert.strictEqual(withPasswords('up', 'other', () => auth.verify(token)), null); + // works without an admin password too + const { token: t2 } = withPasswords('up', false, () => auth.issue({}, 60)); + assert.ok(withPasswords('up', false, () => auth.verify(t2))); +}); + +test('an explicit token secret survives password changes', () => { + const issue = pass => { + config.tokenSecret = 'fixed-secret'; + return withPasswords(pass, 'ad', () => { config.tokenSecret = 'fixed-secret'; return auth.issue({}, 60); }); + }; + const { token } = issue('up'); + config.tokenSecret = 'fixed-secret'; + const before = { uploadPass: config.uploadPass }; + config.uploadPass = 'changed'; + try { + assert.ok(auth.verify(token)); + } finally { + config.tokenSecret = ''; + config.uploadPass = before.uploadPass; + } +}); + +test('safeEqual compares secrets', () => { + assert.strictEqual(auth.safeEqual('abc', 'abc'), true); + assert.strictEqual(auth.safeEqual('abc', 'abd'), false); + assert.strictEqual(auth.safeEqual('abc', 'abcd'), false); + assert.strictEqual(auth.safeEqual(undefined, 'abc'), false); + assert.strictEqual(auth.safeEqual(undefined, undefined), true); +}); diff --git a/tests/unit/db-remove.test.js b/tests/unit/db-remove.test.js new file mode 100644 index 0000000..d50e0fa --- /dev/null +++ b/tests/unit/db-remove.test.js @@ -0,0 +1,66 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const Store = require('../../lib/store'); +const DB = require('../../lib/db'); + +const KEY_A = '00000000-0000-4000-8000-00000000000a'; +const KEY_B = '00000000-0000-4000-8000-00000000000b'; + +function setup() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-db-')); + const db = new DB(dir, new Store(dir)); + db.init(); + fs.mkdirSync(path.join(dir, 'bucket')); + for (const key of [KEY_A, KEY_B]) { + fs.writeFileSync(path.join(dir, 'bucket', key), 'x'); + fs.writeFileSync(path.join(dir, 'bucket', `${ key }.json`), '{}'); + db.add('bucket', key, { metadata: { sid: 'bucket', key } }); + } + return { dir, db }; +} + +test('concurrent remove of the same file neither throws nor removes other files', async () => { + const { dir, db } = setup(); + try { + await Promise.all([db.remove('bucket', KEY_A), db.remove('bucket', KEY_A)]); + assert.deepStrictEqual(db.get('bucket').map(f => f.key), [KEY_B]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('remove tolerates an already deleted bucket', async () => { + const { dir, db } = setup(); + try { + await Promise.all([ + db.remove('bucket', KEY_A), + db.remove('bucket', KEY_B), + db.remove('bucket', KEY_B), + ]); + assert.strictEqual(db.get('bucket'), undefined); + await db.remove('bucket', KEY_A); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('claim is exclusive until released', () => { + const { dir, db } = setup(); + try { + assert.strictEqual(db.claim('bucket', KEY_A), true); + assert.strictEqual(db.claim('bucket', KEY_A), false); + assert.strictEqual(db.isClaimed('bucket', KEY_A), true); + db.release('bucket', KEY_A); + assert.strictEqual(db.claim('bucket', KEY_A), true); + assert.strictEqual(db.claim('bucket', 'unknown'), false); + assert.strictEqual(db.claim('nobucket', KEY_A), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/settings.test.js b/tests/unit/settings.test.js new file mode 100644 index 0000000..e5f3e0f --- /dev/null +++ b/tests/unit/settings.test.js @@ -0,0 +1,103 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-settings-')); +process.env.PSITRANSFER_UPLOAD_DIR = tmpDir; + +const { test, beforeEach, after } = require('node:test'); +const assert = require('node:assert'); + +const config = require('../../config'); +const settings = require('../../lib/settings'); + +const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)]); +const SVG = Buffer.from('<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>'); + +beforeEach(() => { + fs.rmSync(config.settingsDir, { recursive: true, force: true }); + settings.reset(); +}); + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('settings live inside the data directory', () => { + assert.strictEqual(config.settingsDir, path.join(tmpDir, '.settings')); +}); + +test('brand falls back to the configured defaults', () => { + const brand = settings.brand(); + assert.strictEqual(brand.name, 'Dodo Transfer'); + assert.strictEqual(brand.from, 2026); + assert.strictEqual(brand.customLogo, false); + assert.match(brand.logoUrl, /assets\/brand-logo\?v=[0-9a-f]{10}$/); +}); + +test('footer shows a range once the start year is in the past', () => { + assert.strictEqual(settings.footerText('ACME', 2026, 2026), '© 2026 ACME'); + assert.strictEqual(settings.footerText('ACME', 2026, 2028), '© 2026–2028 ACME'); + assert.strictEqual(settings.footerText('', 2026, 2026), '© 2026'); +}); + +test('update validates and persists', () => { + settings.update({ name: ' My Transfer ', owner: 'Team', from: '2024' }); + assert.deepStrictEqual( + JSON.parse(fs.readFileSync(path.join(config.settingsDir, 'settings.json'), 'utf8')), + { name: 'My Transfer', owner: 'Team', from: 2024 } + ); + settings.reset(); + assert.strictEqual(settings.brand().name, 'My Transfer'); + + assert.throws(() => settings.update({ name: ' ' }), /name/); + assert.throws(() => settings.update({ name: 'x'.repeat(41) }), /name/); + assert.throws(() => settings.update({ from: 1999 }), /year/); + assert.throws(() => settings.update({ from: 'abc' }), /year/); + assert.throws(() => settings.update({ owner: 'x'.repeat(61) }), /owner/); + // failed updates change nothing + assert.strictEqual(settings.brand().name, 'My Transfer'); +}); + +test('logo is detected by its content', () => { + assert.strictEqual(settings.detectLogo(PNG).mime, 'image/png'); + assert.strictEqual(settings.detectLogo(SVG).mime, 'image/svg+xml'); + assert.strictEqual(settings.detectLogo(Buffer.from('RIFF\0\0\0\0WEBPVP8 ')).mime, 'image/webp'); + assert.strictEqual(settings.detectLogo(Buffer.from('<html><svg></svg></html>')), null); + assert.strictEqual(settings.detectLogo(Buffer.from('GIF89a......')), null); +}); + +test('svg logos with scripts are rejected', () => { + const evil = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'); + assert.throws(() => settings.detectLogo(evil), /script/i); + const handler = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>'); + assert.throws(() => settings.detectLogo(handler), /script/i); +}); + +test('custom logo replaces the default and can be restored', () => { + const before = settings.brand(); + const after1 = settings.setLogo(PNG); + assert.strictEqual(after1.customLogo, true); + assert.notStrictEqual(after1.logoUrl, before.logoUrl, 'url changes with the image (cache busting)'); + assert.ok(fs.existsSync(path.join(config.settingsDir, 'logo.png'))); + + // another type removes the old file + settings.setLogo(SVG); + assert.ok(fs.existsSync(path.join(config.settingsDir, 'logo.svg'))); + assert.ok(!fs.existsSync(path.join(config.settingsDir, 'logo.png'))); + assert.strictEqual(settings.logoInfo().mime, 'image/svg+xml'); + + const restored = settings.clearLogo(); + assert.strictEqual(restored.customLogo, false); + assert.strictEqual(restored.logoUrl, before.logoUrl); + assert.ok(!fs.existsSync(path.join(config.settingsDir, 'logo.svg'))); +}); + +test('invalid logos are refused', () => { + assert.throws(() => settings.setLogo(Buffer.alloc(0)), /no image/); + assert.throws(() => settings.setLogo(Buffer.from('not an image at all')), /PNG/); + assert.throws(() => settings.setLogo(Buffer.concat([PNG, Buffer.alloc(settings.MAX_LOGO_SIZE)])), /1MB/); + assert.strictEqual(settings.brand().customLogo, false); +}); diff --git a/tests/unit/store-containment.test.js b/tests/unit/store-containment.test.js new file mode 100644 index 0000000..82257a0 --- /dev/null +++ b/tests/unit/store-containment.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const Store = require('../../lib/store'); + +const SAMPLE_UUID = '00000000-0000-4000-8000-000000000001'; + +test('getFilename rejects prefix false-positive (startsWith bypass)', () => { + const jailParent = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-jail-')); + try { + const base = path.join(jailParent, 'conf'); + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(path.join(jailParent, 'config.production.js'), 'x'); + const store = new Store(base); + assert.throws(() => store.getFilename('..++config.production.js'), /jail/); + } finally { + fs.rmSync(jailParent, { recursive: true, force: true }); + } +}); + +test('getFilename rejects traversal via multiple ++ segments', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws(() => store.getFilename('legit++..++..++outside'), /jail/); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename rejects NUL in fid', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws( + () => store.getFilename(`legit++${SAMPLE_UUID}\0/../../../etc/passwd`), + /jail/ + ); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename rejects resolved path equal to jail root (rel empty)', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws(() => store.getFilename('bucket++..'), /jail/); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename accepts valid sid++uuid path under jail', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + const fid = `mybucket++${SAMPLE_UUID}`; + const got = store.getFilename(fid); + const expected = path.join(base, 'mybucket', SAMPLE_UUID); + assert.strictEqual(got, expected); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/upload-id.test.js b/tests/unit/upload-id.test.js new file mode 100644 index 0000000..c201a83 --- /dev/null +++ b/tests/unit/upload-id.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const utils = require('../../lib/utils'); + +const UUID = '00000000-0000-4000-8000-000000000001'; + +test('isSafeTusUploadId rejects sid with path traversal', () => { + assert.strictEqual(utils.isSafeTusUploadId(`../../evil++${UUID}`), false); +}); + +test('isSafeTusUploadId rejects non-UUID key', () => { + assert.strictEqual(utils.isSafeTusUploadId('legit++not-a-uuid'), false); +}); + +test('isSafeTusUploadId rejects extra ++ separators', () => { + assert.strictEqual(utils.isSafeTusUploadId('a++b++c'), false); +}); + +test('isSafeTusUploadId rejects empty sid or key', () => { + assert.strictEqual(utils.isSafeTusUploadId(`++${UUID}`), false); + assert.strictEqual(utils.isSafeTusUploadId('sid++'), false); +}); + +test('isSafeTusUploadId accepts valid compound id', () => { + assert.strictEqual(utils.isSafeTusUploadId(`abc12++${UUID}`), true); +}); + +test('isSafeBucketFid rejects traversal-shaped id', () => { + assert.strictEqual(utils.isSafeBucketFid('../etc'), false); +}); + +test('isSafeBucketFid rejects compound id', () => { + assert.strictEqual(utils.isSafeBucketFid(`x++${UUID}`), false); +}); + +test('isSafeBucketFid accepts plain bucket id', () => { + assert.strictEqual(utils.isSafeBucketFid('abc123'), true); +});