diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0b50175 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Report a bug or unexpected behavior in the project +title: "Bug Report" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please fill out the details below so we can investigate quickly. + + - type: input + id: summary + attributes: + label: Short summary + description: One-line description of the issue + placeholder: e.g. Login form fails on empty email + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: List the exact steps that cause the issue + placeholder: | + 1. Open the app + 2. Go to login + 3. Submit the form + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What should happen instead? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What happened instead? + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: Browser, OS, Node version, app route, or other relevant details + placeholder: | + - OS: + - Browser: + - Node version: + - App route: + - Build or branch: + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Relevant logs or screenshots + description: Add console output, screenshots, or stack traces if available + placeholder: Paste logs or drag screenshots here + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..dd7de27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Suggest a new feature or enhancement for the project +title: "Feature Request" +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement. Please describe the idea and its value clearly. + + - type: input + id: summary + attributes: + label: Feature summary + description: A brief title for the idea + placeholder: e.g. Add category filters to the library page + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem or need + description: What problem are you trying to solve? + placeholder: Describe the limitation or user need. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Explain the feature or workflow you want to add + placeholder: Describe the behavior or UI changes you expect. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + placeholder: What other approaches or workarounds have you considered? + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Add screenshots, user stories, references, or implementation ideas + placeholder: Add any extra context here. + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1df907d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ +# Pull Request + +## Summary + +Describe the purpose of this pull request and the problem it solves. + +## Changes made + +- [ ] Feature added +- [ ] Bug fixed +- [ ] Documentation updated +- [ ] Tests added or updated +- [ ] UI/UX changes +- [ ] Backend/API changes + +## Why this change is needed + +Explain the motivation, user impact, or issue being addressed. + +## Testing + +Describe how this was validated. + +- [ ] Manual testing completed +- [ ] Automated tests run +- [ ] No relevant tests were available + +## Notes + +Add any migration steps, follow-up tasks, screenshots, or blockers. diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml new file mode 100644 index 0000000..9e9b7b3 --- /dev/null +++ b/.github/workflows/frontend-build.yml @@ -0,0 +1,43 @@ +name: Frontend build + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint frontend + run: npm run lint + + - name: Build frontend and generate SEO assets + run: npm run build + + - name: Upload production output + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72bc7dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +node_modules/ +.env +uploads/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Generated from src/seo/seoCatalog.mjs during the build. +frontend/public/sitemap.xml +frontend/public/robots.txt +react-snap-routes.json + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +%SystemDrive%/ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..078d4c0 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,10 @@ +node_modules +dist +coverage +backend +uploads +*.log +.env +.env.* +!.env.example +.vscode diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0d02698 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Code of Conduct + +## Our Pledge + +We are committed to building SignPakCommons as a respectful, inclusive, and collaborative project. We welcome contributors from all backgrounds, skill levels, and perspectives, and we expect all participation in this project to be constructive and professional. + +## Our Standards + +Examples of behavior that contributes to a positive environment include: + +- being kind, respectful, and patient with others +- giving and receiving feedback constructively +- focusing on the project goals, user impact, and technical quality +- acknowledging the work and effort of other contributors +- helping others learn and grow in a supportive way + +Examples of unacceptable behavior include: + +- harassment, intimidation, or personal attacks +- discriminatory, insulting, or exclusionary language +- threatening or abusive communication +- repeatedly derailing discussions or blocking progress without cause +- sharing private information without consent +- behavior that undermines the safety or dignity of contributors or users + +## Scope + +This Code of Conduct applies to all project spaces, including: + +- GitHub issues, pull requests, and discussions +- repository communication channels +- meetings, events, and project-related collaboration +- any public or private activity associated with SignPakCommons + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing this Code of Conduct. They may take any action they consider appropriate, including: + +- editing or removing harmful content +- asking for a change in behavior +- temporarily restricting participation +- removing access or banning contributors in serious or repeated cases + +## Reporting + +If you experience or witness unacceptable behavior, please report it privately to the project maintainers through the repository owner contact channels or the appropriate project communication route. Include as much context as possible, such as links, screenshots, dates, and the people involved. + +Reports will be reviewed promptly and handled confidentially, with a focus on maintaining a safe and respectful community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant and tailored to the SignPakCommons community. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1cca4b7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to SignPakCommons + +Thank you for your interest in contributing to SignPakCommons. This project exists to support Pakistan Sign Language data collection and accessibility work, and we value thoughtful, respectful contributions. + +## Ways to Contribute + +You can help in several ways: + +- report bugs or usability issues +- propose features or improvements +- improve documentation +- help with frontend or backend development +- improve testing or QA coverage +- contribute to data collection workflows and validation + +## Before You Start + +1. Read the project overview in [README.md](README.md). +2. Check the open issues and pull requests to avoid duplicating work. +3. Start with a small, focused change if you are new to the project. +4. Keep discussions respectful and grounded in the project mission and user impact. + +## Development Setup + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +### Backend + +```bash +cd backend +npm install +npm run dev +``` + +For local backend work, create the environment configuration required by the app before starting the server, using the sample environment file or setup notes in the backend documentation. + +## Contribution Workflow + +1. Fork the repository and create a branch for your work. +2. Make focused changes with clear commit messages. +3. Run the relevant checks for the area you modified. +4. Open a pull request with a clear description of the change and why it matters. +5. Be responsive to review feedback and update the PR as needed. + +## Pull Request Guidelines + +- Keep changes small and easy to review. +- Explain the problem being solved and the expected outcome. +- Include screenshots or examples when the change affects user-facing behavior. +- Update relevant documentation when the behavior or workflow changes. +- Ensure the code follows the project's existing patterns and conventions. + +## Coding Standards + +- Write readable, maintainable code. +- Prefer small, clear functions and simple logic. +- Follow the repo's existing style and naming conventions. +- Add or update tests when the change affects behavior. + +## Community Expectations + +Please read and follow the project's [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). We aim to make this a welcoming and productive environment for everyone. + +## Questions + +If you are unsure where to start, open an issue and ask. We are happy to help guide new contributors. diff --git a/README.md b/README.md index beb0a22..dcc198f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ # SignPakCommons -sigpakcommons is an open-source infrastructure designed to crowdsource Pakistan Sign Language (PSL) datasets. Join us in building accessible tools, collecting quality data, and empowering the deaf and hard-of-hearing community across Pakistan. + +Thank you to everyone who is helping build this project and contribute to the dataset. Your time, effort, and support are making a real difference for the deaf and hard-of-hearing community in Pakistan. + +SignPakCommons is a community-driven initiative created to collect and organize Pakistan Sign Language (PSL) data for a better future of communication, accessibility, and inclusion. We believe that language should never be a barrier, and by building this open-source dataset, we are helping create tools that can support education, communication, and everyday digital access for people who use sign language. + +This project is being developed by NUST students as part of our final year project, with a simple but powerful purpose: to build meaningful technology for people with hearing difficulties and make the world more inclusive for everyone. + +Every sign, every video, and every contribution helps us move closer to a future where communication is easier, smarter, and more accessible. We are deeply grateful to everyone who contributes their time, recordings, feedback, and encouragement. Together, we are building something valuable for the people who need it most. + +We also want to thank all contributors who are helping grow this dataset, improve the platform, and support this mission with their work and generosity. Your effort is helping create real impact and bringing long-term accessibility solutions to life. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..069f17a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +This project is under active development. Security updates are prioritized for the current main branch and any actively maintained release line. + +## Reporting a Vulnerability + +Please do not report security vulnerabilities through public issues or pull requests. + +If you believe you have found a security issue, report it privately to the maintainers through the repository owner contact channels or the project's private security reporting route. Please include: + +- a clear description of the vulnerability +- affected components or endpoints +- steps to reproduce the issue +- potential impact +- any suggested fix or mitigation + +## Response Expectations + +We will make our best effort to: + +- acknowledge valid reports promptly +- assess the severity and impact +- provide updates as the investigation progresses +- release a fix or mitigation as quickly as possible when needed + +## Responsible Disclosure + +We ask that reporters work with us to keep the issue private until a fix is available. Please do not disclose the vulnerability publicly before a coordinated resolution has been completed. + +## Scope + +This policy applies to security issues in the project codebase, configuration, deployment workflows, and project-maintained services related to SignPakCommons. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..61caf01 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,40 @@ +# Copy this file to .env and fill it in. Never commit .env. + +NODE_ENV=development +PORT=5000 +LOG_LEVEL=info +# Set to true when the API runs behind a reverse proxy (nginx, Render, Railway...) +TRUST_PROXY=false + +MONGODB_URI=mongodb://127.0.0.1:27017/signpakcommons + +# Browser origin(s) allowed to call the API with cookies. Comma-separate several. +CLIENT_ORIGIN=http://localhost:5173 + +# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +JWT_SECRET= +JWT_EXPIRES_DAYS=7 +# Defaults to true in production, false otherwise. Must be true when COOKIE_SAMESITE=none. +# COOKIE_SECURE=false +# lax works when the app and API share a site (localhost, or a Vite proxy in dev). +# Use none (with COOKIE_SECURE=true) only if they live on different sites. +COOKIE_SAMESITE=lax +BCRYPT_ROUNDS=12 + +# The admin account is created (or promoted) from these on every start. +ADMIN_EMAIL=admin@signpak.dev +ADMIN_PASSWORD= +ADMIN_FIRST_NAME=Admin +ADMIN_SURNAME=Signpak + +# Where uploaded files go. Only "local" exists today; a Google Drive driver plugs in here later. +STORAGE_DRIVER=local +UPLOAD_DIR=uploads +MAX_VIDEO_UPLOAD_MB=300 +MAX_RECORDING_UPLOAD_MB=100 +SUBMISSION_COOLDOWN_MS=30000 + +RATE_LIMIT_ENABLED=true + +# Minimum gap, in milliseconds, between two submissions for the same (user, video). +SUBMISSION_COOLDOWN_MS=30000 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..b03f66a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,109 @@ +# SignPakCommons: backend + +Express + Node.js + MongoDB (Mongoose). Cookie-based auth, local file storage (Google Drive ready), layered architecture. + +## Run it + +```bash +cd backend +npm install +cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD and MONGODB_URI +npm run dev # http://localhost:5000, health check at /api/v1/health +npm test # integration tests (see "Testing") +``` + +Needs Node 20+ and a MongoDB (local install or an Atlas connection string). On start the API validates `.env` and tells you exactly what is missing, then connects, creates the admin account from `ADMIN_EMAIL` / `ADMIN_PASSWORD`, and listens. + +## How a request flows + +routes -> middlewares -> controllers -> services -> repositories -> models -> MongoDB + +Dependencies only point down. Controllers never touch models; services never see `req`/`res`. + +| Folder | Job | +| --- | --- | +| `src/routes/` | Which URL + method goes where, and which guards it passes through | +| `src/middlewares/` | Auth, roles, validation (`validators/` holds the Zod schemas), uploads, rate limits, origin check, error handler | +| `src/controllers/` | Read the request, call one service, send `{ data }`. No logic | +| `src/services/` | All business rules. `storage/` is the file-storage driver layer | +| `src/repositories/` | The only place that queries the database. No rules | +| `src/models/` | Mongoose schemas, indexes, JSON shape | +| `src/utils/` | Typed errors, logger, tokens, file sniffing, Range streaming | +| `src/config/` | Validated env, DB connection, constants | + +`src/app.js` builds the app (no port), `server.js` connects and listens, so tests can import `app` directly. + +Interactive API documentation is available at `http://localhost:5000/api/v1/docs` when the server is running. The raw OpenAPI document is at `/api/v1/docs.json`. See [docs/API.md](docs/API.md) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the human-readable reference. + +## Responses + +```jsonc +// success: data is the resource, or an array of them +{ "data": { "id": "...", "title": "..." } } + +// failure: `fields` maps input names to messages, ready for form errors +{ "error": { "code": "VALIDATION_ERROR", "message": "Some fields need attention.", "fields": { "email": "Enter a valid email address." } } } +``` + +Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, `CONFLICT` 409, `PAYLOAD_TOO_LARGE` 413, `VALIDATION_ERROR` 422, `TOO_MANY_REQUESTS` 429, `INTERNAL_ERROR` 500. `DELETE` returns `204` with no body. Every response carries an `X-Request-Id` that matches the log line. + +## Endpoints (all under `/api/v1`) + +| Method + path | Who | Notes | +| --- | --- | --- | +| `GET /health` | anyone | 503 if the database is down | +| `POST /auth/signup` | anyone | Always creates a contributor. Sets the cookie | +| `POST /auth/login` | anyone | Sets the cookie | +| `POST /auth/logout` | anyone | Clears the cookie | +| `GET /auth/session` | anyone | `{ data: user }`, or `{ data: null }` for visitors | +| `GET /users/me` | logged in | | +| `PATCH /users/me` | logged in | `{ connections: { github, linkedin } }`; a handle or a profile link; `null` disconnects | +| `GET /categories` | anyone | | +| `POST /categories`, `PATCH`/`DELETE /categories/:id` | admin | Deleting unassigns its videos, it does not delete them | +| `GET /videos`, `GET /videos/:id` | anyone | Visitors and contributors: published + categorised only. Admin: everything | +| `GET /videos/:id/poster` | anyone (same visibility) | | +| `GET /videos/:id/file` | anyone (same visibility) | Published reference videos stream with Range support | +| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `level`, `status`, `durationSec` | +| `PATCH`/`DELETE /videos/:id` | admin | Moving to another category appends to the end of that path | +| `GET /submissions` | logged in | Contributors: their own. Admin: all | +| `POST /submissions` | logged in | `multipart/form-data`: `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, `duration` | +| `GET /submissions/:id/recording` | admin | Contributors can never play a submission back | +| `POST /contact` | anyone | Rate limited: 5 per hour per IP | +| `GET /admin/users`, `/admin/stats?days=14`, `/admin/messages` | admin | | + +## Decisions worth knowing + +**Auth.** A signed JWT in an `httpOnly` cookie (`signpak_token`), so page scripts cannot read it. The user is reloaded from the database on every request, so role changes and deletions apply immediately. Login answers identically for "wrong password" and "no such email", and does the same amount of hashing work for both. + +**CSRF.** `SameSite=Lax` plus an `Origin` check: any `POST/PATCH/DELETE` carrying an `Origin` header outside `CLIENT_ORIGIN` is refused with 403. + +**Admin.** Created or promoted from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on every start. Signup ignores any `role` in the body. Changing `ADMIN_PASSWORD` later does not reset an existing admin's password. + +**The cooldown rule.** This is a data-collection tool, not a one-shot quiz: a contributor can submit the same video more than once (more varied takes make better training data), but not back to back. `SUBMISSION_COOLDOWN_MS` (default 30s, in `.env`) is the minimum gap between two submissions for the same `(user, video)`. It is enforced atomically, not by a simple read-then-write: `repositories/submissionRepo.js#claimCooldown` does a conditional `findOneAndUpdate` with `upsert: true` against a `SubmissionCooldown` collection that has a unique index on `(user, video)`. If a live cooldown already exists, the upsert's insert path collides with that unique index (`E11000`), which is treated as "still cooling down" — so three simultaneous submissions for the same video still produce exactly one success (verified: `tests/api.test.js`'s concurrent-submit test). A submission that fails after claiming the cooldown (a storage or DB error) releases it, so a failed attempt never costs the contributor their next try. Regardless of timing, no submission can ever be edited or deleted once made, and only an admin can watch one back. + +**Uploads.** Multer writes to `uploads/tmp`, the service checks the file's real type from its first bytes (MP4, WebM or MOV; JPEG, PNG or WebP for posters; the browser-supplied type is not trusted), then hands it to storage. Failures at any step leave no files behind. + +**Storage and Google Drive.** MongoDB stores only `{ driver, key, mimeType, size }`. `services/storage/localDriver.js` documents a four-method contract (`save`, `stat`, `createReadStream`, `remove`). To add Google Drive: write `googleDriveDriver.js` with those four methods (Drive's `files.get` with `alt=media` accepts Range headers), register it in `services/storage/index.js`, and allow `gdrive` in `STORAGE_DRIVER`. Files already stored keep working, because each one remembers which driver holds it. + +**Stats.** Per-category counts only include submissions whose video still exists. Totals and daily activity always count everything. Days are UTC. Per-day bucketing is done in code from a timestamps-only query; if submissions reach hundreds of thousands, move it to a MongoDB aggregation. + +**Client-reported values.** `durationSec` and `duration` come from the browser until the ffmpeg step exists to measure them on the server. + +## Testing + +`npm test` runs `tests/api.test.js` (31 tests): auth, roles, uploads, Range streaming, the cooldown rule (including a real elapsed-cooldown resubmission, sped up via `SUBMISSION_COOLDOWN_MS=200` in `tests/helpers.js`), stats, contact, cleanup of temp files. It calls the real HTTP stack. + +- Set `MONGODB_URI_TEST` to run against a MongoDB you already have (it uses that database and **drops it**, so point it at a throwaway one). +- Otherwise it starts `mongodb-memory-server`, which downloads a MongoDB binary on first use. + +## Not built yet + +- **ffmpeg trimming and probing.** Submissions store `trimStart` / `trimEnd` / `mirrored` but the recording is saved untouched. + +## Housekeeping + +`package.json` still lists packages this code does not use: `bcryptjs`, `express-validator`, `morgan`, `pg`, `pg-hstore`, `sequelize`. `sequelize` is also the cause of the two moderate `npm audit` warnings (an old `uuid`). Safe to remove: + +```bash +npm uninstall bcryptjs express-validator morgan pg pg-hstore sequelize +``` diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..2254b33 --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,76 @@ +# SignPak Commons API + +The interactive API reference is available while the backend is running: + +- Swagger UI: `http://localhost:5000/api/v1/docs` +- Swagger UI aliases: `http://localhost:5000/docs` or `http://localhost:5000/api-docs` +- OpenAPI JSON: `http://localhost:5000/api/v1/docs.json` +- OpenAPI JSON aliases: `/docs.json` or `/api-docs.json` +- API base URL: `http://localhost:5000/api/v1` + +The OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openapi.js). Keep it synchronized with the route files and validators when changing the API. + +## Authentication + +Signup and login set an HTTP-only `signpak_token` cookie. Browser clients must send requests with credentials enabled. Swagger UI can describe the cookie scheme, but browser authentication is normally established by calling `/auth/signup` or `/auth/login` first. + +The cookie is cleared by `POST /auth/logout`. `GET /auth/session` is public and returns the current user when a valid session exists, or `null` for a visitor. + +## Response format + +JSON success responses use: + +```json +{ "data": { "id": "..." } } +``` + +Errors use a structured object suitable for form-level and field-level messages: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Some fields need attention.", + "fields": { "email": "Enter a valid email address." } + } +} +``` + +`204 No Content` responses have no body. Responses include `X-Request-Id`, which can be supplied by a client and matched against server logs. + +## Endpoint summary + +| Method | Path | Access | Purpose | +| --- | --- | --- | --- | +| GET | `/health` | Public | Check API and database status. | +| POST | `/auth/signup` | Public | Create a contributor account and session. | +| POST | `/auth/login` | Public | Create a session. | +| POST | `/auth/logout` | Public | Clear the session. | +| GET | `/auth/session` | Public | Read the current session. | +| GET/PATCH | `/users/me` | User | Read or update the current profile. | +| GET | `/categories` | Public | List categories. | +| POST/PATCH/DELETE | `/categories`, `/categories/:id` | Admin | Manage categories. | +| GET | `/videos`, `/videos/:id` | Public | List or read visible video metadata. | +| GET | `/videos/:id/file` | Public when published | Stream a published reference video. | +| GET | `/videos/:id/poster` | Public when published | Stream a published poster. | +| POST/PATCH/DELETE | `/videos`, `/videos/:id` | Admin | Upload and manage base videos. | +| GET/POST | `/submissions` | User | List or create contributor recordings. | +| GET | `/submissions/:id/recording` | Admin | Stream a contributor recording. | +| POST | `/contact` | Public | Send a contact message. | +| GET | `/admin/users` | Admin | List users. | +| GET | `/admin/stats` | Admin | Read dashboard statistics. | +| GET | `/admin/messages` | Admin | Read contact messages. | + +## Uploads + +Video creation uses `multipart/form-data` with `video`, optional `poster`, `title`, optional `categoryId`, `status`, and `durationSec`. Submission creation uses `multipart/form-data` with `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration`. + +The server validates file signatures, size limits, metadata, and route IDs. Client-reported durations are currently accepted for trimming metadata; server-side media probing and trimming are future work. + +## Visibility rules + +- Anonymous users see only published videos assigned to a category. +- Admins can list and manage draft, published, and unassigned videos. +- Contributor submissions are private to their owner in `/submissions`. +- Only admins can stream submission recordings. +- Video reference files are public only when the corresponding video passes the same published visibility check. diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f6c82fe --- /dev/null +++ b/backend/docs/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# SignPak Commons Backend Architecture + +## Runtime + +The backend is an Express application running on Node.js 20+ with MongoDB through Mongoose. `server.js` owns process startup, environment validation, database connection, admin bootstrap, and the listening port. `src/app.js` builds and exports the HTTP application so integration tests can exercise the same middleware and routes without opening a port. + +## Request flow + +```text +HTTP request + -> app middleware (logging, security headers, CORS, compression, parsing) + -> API prefix middleware (rate limit, origin verification) + -> route guards (authentication, role checks, validation, upload parsing) + -> controller + -> service + -> repository + -> Mongoose model + -> MongoDB +``` + +The dependency direction is one way: controllers do not query models, services do not read `req` or write `res`, and repositories do not contain business rules. + +## Responsibilities + +| Layer | Location | Responsibility | +| --- | --- | --- | +| Application | `src/app.js` | Compose global middleware, API routes, and error handling. | +| Routes | `src/routes/` | Map HTTP methods and paths to guards and controllers. Swagger UI is exposed at `/api/v1/docs`. | +| Middleware | `src/middlewares/` | Authentication, roles, validation, uploads, rate limits, origin checks, and errors. | +| Controllers | `src/controllers/` | Translate HTTP input to a service call and return the standard response envelope. | +| Services | `src/services/` | Enforce domain rules, coordinate repositories, and manage stored files. | +| Repositories | `src/repositories/` | Encapsulate MongoDB queries and persistence operations. | +| Models | `src/models/` | Define Mongoose schemas, indexes, and public JSON serialization. | +| Storage | `src/services/storage/` | Hide file storage behind save, stat, stream, and remove operations. | +| Configuration | `src/config/` | Validate environment variables, database state, and shared constants. | +| Documentation | `src/docs/`, `docs/` | Maintain the OpenAPI contract and human-readable system documentation. | + +## API boundary + +All application endpoints are mounted below `/api/v1`. Successful JSON responses use `{ "data": value }`; validation and application failures use `{ "error": { ... } }`. Mutating requests with an `Origin` header must come from a configured client origin. Every request receives an `X-Request-Id` for support and log correlation. + +The authentication token is stored in the `httpOnly` `signpak_token` cookie. The server reloads the user from MongoDB on authenticated requests, so role and account changes take effect without waiting for a token refresh. See [API.md](./API.md) for the endpoint contract and `/api/v1/docs` for the interactive OpenAPI reference. + +## Data and media + +MongoDB stores users, categories, videos, submissions, cooldown records, and contact messages. Video and recording bytes are stored through the storage driver; MongoDB stores a driver reference, key, MIME type, and size rather than file contents. + +Published videos assigned to a category are visible to anonymous visitors. Their metadata, posters, and reference files can be read by the public demo and library. Draft or unassigned videos remain restricted to admins. Contributor recordings are never publicly playable; only admins can access a submission recording. + +Uploads are first written to a temporary directory, checked by file signatures, and moved into storage only after validation. Failure cleanup removes temporary and already-stored files where necessary. + +## Security controls + +- Helmet security headers and disabled `x-powered-by`. +- CORS with an explicit client-origin allowlist and credentials enabled. +- SameSite HTTP-only JWT cookies plus origin verification for state-changing requests. +- Global and operation-specific rate limits. +- Zod validation for JSON, multipart fields, route IDs, and query parameters. +- Role checks for admin routes and submission recording playback. +- File type sniffing instead of trusting browser-provided MIME types. + +## Testing and extension points + +`npm test` runs the HTTP integration suite. The storage driver contract is the extension point for adding a remote provider such as Google Drive. The OpenAPI definition in `src/docs/openapi.js` should be updated whenever a public route, request shape, response shape, or security rule changes. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..2090c83 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3349 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.20.0", + "bcrypt": "^6.0.0", + "bcryptjs": "^2.4.3", + "compression": "^1.8.2", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.22.3", + "express-rate-limit": "^8.7.0", + "express-validator": "^7.2.1", + "fs-extra": "^11.4.1", + "helmet": "^8.3.0", + "jsonwebtoken": "^9.0.3", + "mongoose": "^7.8.12", + "morgan": "^1.10.0", + "multer": "^2.4.0", + "openapi-client-axios": "^7.9.0", + "pg": "^8.13.1", + "pg-hstore": "^2.3.4", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "sequelize": "^6.37.5", + "swagger-ui-express": "^5.0.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "mongodb-memory-server": "^10.1.4", + "nodemon": "^3.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.4.tgz", + "integrity": "sha512-05UC0jQsjKAOuXQ0H9Ud9vUTJpZIg+n/FinpR30tI5I8pY2inTfPOZ5OF/cg3Ce/N9MoD1xhRCeOsJtuTbFYlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0", + "peer": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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/axios/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/axios/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/axios/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/axios/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/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "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.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "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.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/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==", + "license": "MIT" + }, + "node_modules/bath-es5": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz", + "integrity": "sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "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/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "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/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "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/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "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/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dereference-json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.3.tgz", + "integrity": "sha512-CBVoEuf2tKhqnUhoKjvX4wo5NVxDSHxInqwExQfUNEK67aX+LPC27Vg+6CApi+KRebABJZAWhHBVoNpKfhMssg==", + "license": "MIT" + }, + "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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "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==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "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/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/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/express-rate-limit/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/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "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": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "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/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT", + "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.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/moment": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "license": "Apache-2.0", + "dependencies": { + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongodb-memory-server": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.4.3.tgz", + "integrity": "sha512-CDZvFisXvGIigsIw5gqH6r9NI/zxGa/uRdutgUL/isuJh+inj0YXb7Ykw6oFMFzqgTJWb7x0I5DpzrqCstBWpg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.4.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.4.3.tgz", + "integrity": "sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.11", + "https-proxy-agent": "^7.0.6", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.7", + "tslib": "^2.8.1", + "yauzl": "^3.2.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongodb-memory-server-core/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mongodb-memory-server-core/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongoose": { + "version": "7.8.12", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.12.tgz", + "integrity": "sha512-OLM9mdTuAc8oNDSJ9yoNtsMFFvHxQh/QAWR+yW0GwsXPywn/sdfHfOZnD2rZnNCMl7mo8EZM4zAK4ZOTuq7sMg==", + "license": "MIT", + "dependencies": { + "bson": "^5.5.0", + "kareem": "2.5.1", + "mongodb": "5.9.2", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/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/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/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/mquery/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/mquery/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/new-find-package-json/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/new-find-package-json/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "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-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openapi-client-axios": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/openapi-client-axios/-/openapi-client-axios-7.9.0.tgz", + "integrity": "sha512-1VRBbbNQTz6pAWFALXrqr88GclEb+LirqbzmLnFzqOlnCFC2Ao5Gv4JYf783+A8PQbEAu5Or4Rg32RaetllnwA==", + "license": "MIT", + "dependencies": { + "bath-es5": "^3.0.3", + "dereference-json-schema": "^0.2.1", + "openapi-types": "^12.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/anttiviljami" + }, + "peerDependencies": { + "axios": ">=0.25.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-hstore": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", + "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", + "license": "MIT", + "dependencies": { + "underscore": "^1.13.1" + }, + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "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/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "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": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/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/sequelize/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": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.33.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz", + "integrity": "sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..125b713 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "SignPakCommons API: Express, Node.js and MongoDB", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "node --test --test-concurrency=1" + }, + "keywords": [], + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "dependencies": { + "axios": "^1.20.0", + "bcrypt": "^6.0.0", + "bcryptjs": "^2.4.3", + "compression": "^1.8.2", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.22.3", + "express-rate-limit": "^8.7.0", + "express-validator": "^7.2.1", + "fs-extra": "^11.4.1", + "helmet": "^8.3.0", + "jsonwebtoken": "^9.0.3", + "mongoose": "^7.8.12", + "morgan": "^1.10.0", + "multer": "^2.4.0", + "openapi-client-axios": "^7.9.0", + "pg": "^8.13.1", + "pg-hstore": "^2.3.4", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "sequelize": "^6.37.5", + "swagger-ui-express": "^5.0.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "mongodb-memory-server": "^10.1.4", + "nodemon": "^3.1.9" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..63c1f48 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,32 @@ +import app from './src/app.js' +import { connectDb, disconnectDb } from './src/config/db.js' +import { env } from './src/config/env.js' +import { authService } from './src/services/authService.js' +import { storageService } from './src/services/storage/index.js' +import { logger } from './src/utils/logger.js' + +async function start() { + await connectDb() + await storageService.init() + await authService.ensureAdminFromEnv() + + const server = app.listen(env.PORT, () => logger.info(`API listening on http://localhost:${env.PORT}`)) + + let closing = false + const shutdown = async (signal) => { + if (closing) return + closing = true + logger.info({ signal }, 'Shutting down') + server.close(async () => { + await disconnectDb() + process.exit(0) + }) + setTimeout(() => process.exit(1), 10_000).unref() // do not hang forever on open connections + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) +} + +process.on('unhandledRejection', (reason) => { logger.fatal({ err: reason }, 'Unhandled rejection'); process.exit(1) }) + +start().catch((error) => { logger.fatal({ err: error }, 'Failed to start'); process.exit(1) }) diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..9709bbf --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,55 @@ +import compression from 'compression' +import cookieParser from 'cookie-parser' +import cors from 'cors' +import express from 'express' +import helmet from 'helmet' +import { randomUUID } from 'node:crypto' +import pinoHttp from 'pino-http' +import { API_PREFIX } from './config/constants.js' +import { env } from './config/env.js' +import { openapi } from './docs/openapi.js' +import { errorHandler, notFoundHandler } from './middlewares/errorHandler.js' +import { apiLimiter } from './middlewares/rateLimiter.js' +import { verifyOrigin } from './middlewares/verifyOrigin.js' +import routes from './routes/index.js' +import { logger } from './utils/logger.js' +import swaggerUi from 'swagger-ui-express' + +const app = express() + +app.disable('x-powered-by') +if (env.TRUST_PROXY) app.set('trust proxy', 1) // needed behind nginx/Render/Heroku so rate limits see real IPs + +// Request logging with an id that is also sent back, so a support report can be matched to a log line. +app.use(pinoHttp({ + logger, + genReqId: (req, res) => { const id = req.headers['x-request-id'] || randomUUID(); res.setHeader('X-Request-Id', id); return id }, + autoLogging: { ignore: (req) => req.url.endsWith('/health') }, + customLogLevel: (req, res, error) => (error || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'), +})) + +// same-site lets the app on localhost:5173 load videos and posters from the API on localhost:5000. +app.use(helmet({ crossOriginResourcePolicy: { policy: 'same-site' } })) +app.use(cors({ origin: env.clientOrigins, credentials: true })) +app.use(compression()) +app.use(express.json({ limit: '100kb' })) +app.use(cookieParser()) + +app.get('/', (req, res) => res.json({ data: { + name: 'SignPak Commons API', + api: API_PREFIX, + health: `${API_PREFIX}/health`, + docs: '/docs', +} })) +app.get('/favicon.ico', (req, res) => res.status(204).end()) +app.get('/docs.json', (req, res) => res.json(openapi)) +app.get('/api-docs.json', (req, res) => res.json(openapi)) +app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) + +app.use(API_PREFIX, apiLimiter, verifyOrigin, routes) + +app.use(notFoundHandler) +app.use(errorHandler) + +export default app diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js new file mode 100644 index 0000000..676eb8b --- /dev/null +++ b/backend/src/config/constants.js @@ -0,0 +1,14 @@ +export const API_PREFIX = '/api/v1' +export const AUTH_COOKIE = 'signpak_token' + +export const ROLES = { USER: 'user', ADMIN: 'admin' } +export const ROLE_LIST = Object.values(ROLES) + +export const TONES = ['yellow', 'blue', 'red', 'green'] +export const VIDEO_STATUS = { PUBLISHED: 'published', DRAFT: 'draft' } +export const VIDEO_STATUS_LIST = Object.values(VIDEO_STATUS) + +export const POSTER_MAX_BYTES = 2 * 1024 * 1024 +export const MAX_RECORDING_SECONDS = 600 +// Client-reported durations are rounded, so allow a little slack when checking trim ranges. +export const TRIM_TOLERANCE_SECONDS = 0.5 diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..eef6572 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,18 @@ +import mongoose from 'mongoose' +import { env } from './env.js' +import { logger } from '../utils/logger.js' + +mongoose.set('strictQuery', true) + +export async function connectDb(uri = env.MONGODB_URI) { + mongoose.connection.on('disconnected', () => logger.warn('MongoDB disconnected')) + mongoose.connection.on('reconnected', () => logger.info('MongoDB reconnected')) + await mongoose.connect(uri, { serverSelectionTimeoutMS: 8000 }) + logger.info({ db: mongoose.connection.name }, 'MongoDB connected') +} + +export async function disconnectDb() { + await mongoose.disconnect() +} + +export const isDbReady = () => mongoose.connection.readyState === 1 diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..7c4bd04 --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,56 @@ +import 'dotenv/config' +import { z } from 'zod' + +const bool = z.union([z.boolean(), z.enum(['true', 'false']).transform((value) => value === 'true')]) + +const schema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().int().positive().default(5000), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), + TRUST_PROXY: bool.default(false), + + MONGODB_URI: z.string({ error: 'MONGODB_URI is required (for example mongodb://127.0.0.1:27017/signpakcommons)' }).min(1, 'MONGODB_URI is required'), + + // Comma-separated list of browser origins allowed to call the API with cookies. + CLIENT_ORIGIN: z.string().default('http://localhost:5173'), + + JWT_SECRET: z.string({ error: 'JWT_SECRET is required. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(48).toString(\'hex\'))"' }).min(32, 'JWT_SECRET must be at least 32 characters. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(48).toString(\'hex\'))"'), + JWT_EXPIRES_DAYS: z.coerce.number().positive().default(7), + COOKIE_SECURE: bool.optional(), + COOKIE_SAMESITE: z.enum(['lax', 'strict', 'none']).default('lax'), + BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12), + + ADMIN_EMAIL: z.email({ error: 'ADMIN_EMAIL is required and must be a valid email' }), + ADMIN_PASSWORD: z.string({ error: 'ADMIN_PASSWORD is required (at least 8 characters)' }).min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72), + ADMIN_FIRST_NAME: z.string().min(1).default('Admin'), + ADMIN_SURNAME: z.string().min(1).default('Signpak'), + + STORAGE_DRIVER: z.enum(['local']).default('local'), + UPLOAD_DIR: z.string().default('uploads'), + MAX_VIDEO_UPLOAD_MB: z.coerce.number().positive().default(300), + MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(100), + + RATE_LIMIT_ENABLED: bool.default(true), + + // A contributor can submit a video more than once, but not back to back: this is the + // minimum gap enforced between two submissions for the same (user, video). Overridable + // (like BCRYPT_ROUNDS) so tests do not have to wait 30 real seconds. + SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), +}) + +const parsed = schema.safeParse(process.env) +if (!parsed.success) { + const lines = parsed.error.issues.map((issue) => ` - ${issue.path.join('.') || 'env'}: ${issue.message}`) + console.error(`\nInvalid environment configuration:\n${lines.join('\n')}\n\nCopy .env.example to .env and fill in the values.\n`) + process.exit(1) +} + +const data = parsed.data +export const env = Object.freeze({ + ...data, + isProduction: data.NODE_ENV === 'production', + isTest: data.NODE_ENV === 'test', + // Secure cookies by default in production; override for local HTTPS-less setups. + COOKIE_SECURE: data.COOKIE_SECURE ?? data.NODE_ENV === 'production', + clientOrigins: data.CLIENT_ORIGIN.split(',').map((item) => item.trim().replace(/\/$/, '')).filter(Boolean), +}) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js new file mode 100644 index 0000000..360b6a1 --- /dev/null +++ b/backend/src/controllers/adminController.js @@ -0,0 +1,7 @@ +import { statsService } from '../services/statsService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { ok } from '../utils/response.js' + +export const adminController = { + stats: asyncHandler(async (req, res) => ok(res, await statsService.overview(req.query))), +} diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..6ae332c --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,26 @@ +import { authService } from '../services/authService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' +import { clearAuthCookie, setAuthCookie } from '../utils/tokens.js' + +export const authController = { + signup: asyncHandler(async (req, res) => { + const { user, token } = await authService.register(req.body) + setAuthCookie(res, token) + created(res, user) + }), + + login: asyncHandler(async (req, res) => { + const { user, token } = await authService.login(req.body) + setAuthCookie(res, token) + ok(res, user) + }), + + logout: (req, res) => { + clearAuthCookie(res) + noContent(res) + }, + + // Answers 200 with null for visitors, so the app can ask "who am I?" on load without an error. + session: (req, res) => ok(res, req.user), +} diff --git a/backend/src/controllers/categoryController.js b/backend/src/controllers/categoryController.js new file mode 100644 index 0000000..9aaeb5c --- /dev/null +++ b/backend/src/controllers/categoryController.js @@ -0,0 +1,10 @@ +import { categoryService } from '../services/categoryService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' + +export const categoryController = { + list: asyncHandler(async (req, res) => ok(res, await categoryService.list())), + create: asyncHandler(async (req, res) => created(res, await categoryService.create(req.body))), + update: asyncHandler(async (req, res) => ok(res, await categoryService.update(req.params.id, req.body))), + remove: asyncHandler(async (req, res) => { await categoryService.remove(req.params.id); noContent(res) }), +} diff --git a/backend/src/controllers/contactController.js b/backend/src/controllers/contactController.js new file mode 100644 index 0000000..9b98167 --- /dev/null +++ b/backend/src/controllers/contactController.js @@ -0,0 +1,8 @@ +import { contactService } from '../services/contactService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, ok } from '../utils/response.js' + +export const contactController = { + send: asyncHandler(async (req, res) => { await contactService.send(req.body); created(res, { received: true }) }), + list: asyncHandler(async (req, res) => ok(res, await contactService.list(req.query.limit))), +} diff --git a/backend/src/controllers/submissionController.js b/backend/src/controllers/submissionController.js new file mode 100644 index 0000000..75480fb --- /dev/null +++ b/backend/src/controllers/submissionController.js @@ -0,0 +1,11 @@ +import { submissionService } from '../services/submissionService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, ok } from '../utils/response.js' +import { sendFile } from '../utils/sendFile.js' + +export const submissionController = { + create: asyncHandler(async (req, res) => created(res, await submissionService.create(req.user, req.body, req.file))), + list: asyncHandler(async (req, res) => ok(res, await submissionService.list(req.user))), + // Recordings are private: no-store so browsers and proxies never keep a copy. + recording: asyncHandler(async (req, res) => sendFile(req, res, await submissionService.getRecording(req.params.id), { cache: 'private, no-store' })), +} diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js new file mode 100644 index 0000000..dc2bd1f --- /dev/null +++ b/backend/src/controllers/userController.js @@ -0,0 +1,9 @@ +import { userService } from '../services/userService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { ok } from '../utils/response.js' + +export const userController = { + me: (req, res) => ok(res, req.user), + updateMe: asyncHandler(async (req, res) => ok(res, await userService.updateConnections(req.user, req.body.connections))), + list: asyncHandler(async (req, res) => ok(res, await userService.listAll())), +} diff --git a/backend/src/controllers/videoController.js b/backend/src/controllers/videoController.js new file mode 100644 index 0000000..f294963 --- /dev/null +++ b/backend/src/controllers/videoController.js @@ -0,0 +1,14 @@ +import { videoService } from '../services/videoService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' +import { sendFile } from '../utils/sendFile.js' + +export const videoController = { + list: asyncHandler(async (req, res) => ok(res, await videoService.list(req.user))), + get: asyncHandler(async (req, res) => ok(res, await videoService.get(req.params.id, req.user))), + create: asyncHandler(async (req, res) => created(res, await videoService.create(req.user, req.body, req.files))), + update: asyncHandler(async (req, res) => ok(res, await videoService.update(req.params.id, req.body))), + remove: asyncHandler(async (req, res) => { await videoService.remove(req.params.id); noContent(res) }), + file: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getFile(req.params.id, req.user))), + poster: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getPoster(req.params.id, req.user))), +} diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js new file mode 100644 index 0000000..0da1afd --- /dev/null +++ b/backend/src/docs/openapi.js @@ -0,0 +1,142 @@ +const envelope = (schema = {}) => ({ + type: 'object', + properties: { data: schema }, +}) + +const idParameter = { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', pattern: '^[a-f0-9]{24}$' }, +} + +const errorResponse = { + description: 'Error response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { type: 'string' }, + fields: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +} + +const userSecurity = [{ cookieAuth: [] }] +const adminSecurity = userSecurity + +export const openapi = { + openapi: '3.0.3', + info: { + title: 'SignPak Commons API', + version: '1.0.0', + description: 'API for the SignPak Commons Pakistan Sign Language dataset contribution platform.', + }, + servers: [{ url: '/api/v1', description: 'Current API server' }], + tags: [ + { name: 'Health' }, + { name: 'Auth' }, + { name: 'Users' }, + { name: 'Categories' }, + { name: 'Videos' }, + { name: 'Submissions' }, + { name: 'Contact' }, + { name: 'Admin' }, + ], + components: { + securitySchemes: { + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'token', description: 'HTTP-only JWT cookie set by login or signup.' }, + }, + schemas: { + Category: { + type: 'object', + properties: { + id: { type: 'string' }, label: { type: 'string' }, copy: { type: 'string' }, tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'] }, + }, + }, + Video: { + type: 'object', + properties: { + id: { type: 'string' }, title: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, + categoryId: { type: 'string', nullable: true }, order: { type: 'integer' }, durationSec: { type: 'number' }, + videoUrl: { type: 'string' }, poster: { type: 'string', nullable: true }, size: { type: 'integer' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' }, + role: { type: 'string', enum: ['user', 'admin'] }, connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, + }, + }, + AuthInput: { + type: 'object', required: ['email', 'password'], + properties: { + firstName: { type: 'string', maxLength: 60 }, surname: { type: 'string', maxLength: 60 }, + email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string' }, + }, + }, + Submission: { + type: 'object', + properties: { id: { type: 'string' }, videoId: { type: 'string' }, submittedAt: { type: 'string', format: 'date-time' }, duration: { type: 'number' }, mirrored: { type: 'boolean' } }, + }, + Error: { type: 'object', properties: { error: { type: 'object' } } }, + }, + }, + paths: { + '/health': { + get: { tags: ['Health'], summary: 'Check API and database health', responses: { 200: { description: 'Healthy', content: { 'application/json': { schema: envelope({ type: 'object' }) } } }, 503: { description: 'Database unavailable' } } }, + }, + '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } } }, + '/auth/signup': { + post: { tags: ['Auth'], summary: 'Create an account', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 201: { description: 'Account created' }, 400: errorResponse } }, + }, + '/auth/login': { + post: { tags: ['Auth'], summary: 'Log in and set the HTTP-only cookie', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 200: { description: 'Logged in' }, 401: errorResponse } }, + }, + '/auth/logout': { post: { tags: ['Auth'], summary: 'Clear the session cookie', responses: { 204: { description: 'Logged out' } } } }, + '/auth/session': { get: { tags: ['Auth'], summary: 'Get the current session', responses: { 200: { description: 'Session state' } } } }, + '/users/me': { + get: { tags: ['Users'], summary: 'Get the current user', security: userSecurity, responses: { 200: { description: 'Current user' }, 401: errorResponse } }, + patch: { tags: ['Users'], summary: 'Update profile connections', security: userSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { connections: { type: 'object' } } } } } }, responses: { 200: { description: 'Updated user' }, 400: errorResponse } }, + }, + '/categories': { + get: { tags: ['Categories'], summary: 'List categories', responses: { 200: { description: 'Categories', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Category' } }) } } } } }, + post: { tags: ['Categories'], summary: 'Create a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 201: { description: 'Created' }, 403: errorResponse } }, + }, + '/categories/{id}': { + parameters: [idParameter], + patch: { tags: ['Categories'], summary: 'Update a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, + delete: { tags: ['Categories'], summary: 'Delete a category', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + }, + '/videos': { + get: { tags: ['Videos'], summary: 'List visible videos', description: 'Anonymous callers receive published videos assigned to a category. Admins receive all videos.', responses: { 200: { description: 'Videos', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Video' } }) } } } } }, + post: { tags: ['Videos'], summary: 'Upload a base video', security: adminSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['video', 'title'], properties: { video: { type: 'string', format: 'binary' }, poster: { type: 'string', format: 'binary' }, title: { type: 'string' }, categoryId: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, durationSec: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + }, + '/videos/{id}': { + parameters: [idParameter], + get: { tags: ['Videos'], summary: 'Get one visible video', responses: { 200: { description: 'Video' }, 404: errorResponse } }, + patch: { tags: ['Videos'], summary: 'Update video metadata', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Video' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, + delete: { tags: ['Videos'], summary: 'Delete a video and its files', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + }, + '/videos/{id}/file': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video file', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, + '/videos/{id}/poster': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video poster', responses: { 200: { description: 'Image stream' }, 404: errorResponse } } }, + '/submissions': { + get: { tags: ['Submissions'], summary: 'List the current user submissions', security: userSecurity, responses: { 200: { description: 'Submissions' }, 401: errorResponse } }, + post: { tags: ['Submissions'], summary: 'Submit a recording', security: userSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['recording', 'videoId'], properties: { recording: { type: 'string', format: 'binary' }, videoId: { type: 'string' }, trimStart: { type: 'number' }, trimEnd: { type: 'number' }, mirrored: { type: 'boolean' }, duration: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + }, + '/submissions/{id}/recording': { parameters: [idParameter], get: { tags: ['Submissions'], summary: 'Stream a submission recording', security: adminSecurity, responses: { 200: { description: 'Recording stream' }, 403: errorResponse } } }, + '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent' }, 400: errorResponse } } }, + '/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } }, + '/admin/stats': { get: { tags: ['Admin'], summary: 'Get dashboard statistics', security: adminSecurity, parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], responses: { 200: { description: 'Statistics' }, 403: errorResponse } } }, + '/admin/messages': { get: { tags: ['Admin'], summary: 'List contact messages', security: adminSecurity, parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], responses: { 200: { description: 'Messages' }, 403: errorResponse } } }, + }, +} diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js new file mode 100644 index 0000000..212e74b --- /dev/null +++ b/backend/src/middlewares/authenticate.js @@ -0,0 +1,26 @@ +import { AUTH_COOKIE } from '../config/constants.js' +import { userRepo } from '../repositories/userRepo.js' +import { unauthorized } from '../utils/AppError.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { verifyToken } from '../utils/tokens.js' + +async function loadUser(req) { + const token = req.cookies?.[AUTH_COOKIE] + const userId = token && verifyToken(token) + // The user is re-read on every request, so a deleted account or changed role takes effect immediately. + return userId ? userRepo.findById(userId) : null +} + +/** Requires a valid session. Sets req.user. */ +export const authenticate = asyncHandler(async (req, res, next) => { + const user = await loadUser(req) + if (!user) throw unauthorized() + req.user = user + next() +}) + +/** Attaches req.user when there is a valid session, but lets anonymous visitors through. */ +export const optionalAuthenticate = asyncHandler(async (req, res, next) => { + req.user = (await loadUser(req)) || null + next() +}) diff --git a/backend/src/middlewares/errorHandler.js b/backend/src/middlewares/errorHandler.js new file mode 100644 index 0000000..4f83795 --- /dev/null +++ b/backend/src/middlewares/errorHandler.js @@ -0,0 +1,54 @@ +import { MulterError } from 'multer' +import mongoose from 'mongoose' +import { logger } from '../utils/logger.js' +import { AppError, notFound } from '../utils/AppError.js' +import { removeQuietly } from '../utils/files.js' + +export const notFoundHandler = (req, res, next) => next(notFound('That route does not exist.')) + +const MULTER_MESSAGES = { + LIMIT_FILE_SIZE: [413, 'PAYLOAD_TOO_LARGE', 'That file is too large.'], + LIMIT_FILE_COUNT: [400, 'BAD_REQUEST', 'Too many files in one request.'], + LIMIT_UNEXPECTED_FILE: [422, 'VALIDATION_ERROR', 'Unexpected file field.'], +} + +/** Maps anything thrown into { error: { code, message, fields? } }. */ +function normalize(error) { + if (error instanceof AppError) return error + if (error instanceof MulterError) { + const [status, code, message] = MULTER_MESSAGES[error.code] || [400, 'BAD_REQUEST', 'The upload could not be processed.'] + return new AppError(status, code, message, error.field ? { [error.field]: message } : undefined) + } + if (error?.type === 'entity.parse.failed') return new AppError(400, 'BAD_REQUEST', 'The request body is not valid JSON.') + if (error?.type === 'entity.too.large') return new AppError(413, 'PAYLOAD_TOO_LARGE', 'The request body is too large.') + if (error instanceof mongoose.Error.CastError) return new AppError(404, 'NOT_FOUND', 'We could not find that.') + if (error instanceof mongoose.Error.ValidationError) { + const fields = Object.fromEntries(Object.entries(error.errors).map(([name, issue]) => [name, issue.message])) + return new AppError(422, 'VALIDATION_ERROR', 'Some fields need attention.', fields) + } + if (error?.code === 11000) { + const fields = Object.fromEntries(Object.keys(error.keyPattern || {}).map((name) => [name, 'That value is already in use.'])) + return new AppError(409, 'CONFLICT', 'That already exists.', Object.keys(fields).length ? fields : undefined) + } + return null +} + +// Four arguments are what tell Express this is the error handler. +// eslint-disable-next-line no-unused-vars +export async function errorHandler(error, req, res, next) { + // Whatever failed, do not leave half-finished uploads in the temp folder. + const uploaded = [req.file, ...Object.values(req.files || {}).flat()].filter(Boolean) + await Promise.all(uploaded.map((file) => removeQuietly(file.path))) + + const known = normalize(error) + if (!known) { + // Unknown failure: log everything, tell the client nothing. + ;(req.log || logger).error({ err: error }, 'Unhandled error') + if (res.headersSent) return res.destroy() + return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Something went wrong on our side. Try again in a moment.' } }) + } + if (res.headersSent) return res.destroy() + const body = { code: known.code, message: known.message } + if (known.fields) body.fields = known.fields + return res.status(known.status).json({ error: body }) +} diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js new file mode 100644 index 0000000..557729d --- /dev/null +++ b/backend/src/middlewares/rateLimiter.js @@ -0,0 +1,20 @@ +import rateLimit from 'express-rate-limit' +import { env } from '../config/env.js' +import { tooManyRequests } from '../utils/AppError.js' + +const limiter = ({ windowMs, limit, message }) => rateLimit({ + windowMs, + limit, + standardHeaders: 'draft-8', + legacyHeaders: false, + skip: () => !env.RATE_LIMIT_ENABLED, + handler: (req, res, next) => next(tooManyRequests(message)), +}) + +const MINUTE = 60 * 1000 + +export const apiLimiter = limiter({ windowMs: 15 * MINUTE, limit: 1000 }) +// Slows down password guessing and account spam. +export const authLimiter = limiter({ windowMs: 15 * MINUTE, limit: 20, message: 'Too many attempts. Wait 15 minutes and try again.' }) +export const contactLimiter = limiter({ windowMs: 60 * MINUTE, limit: 5, message: 'You have sent a few messages already. Try again later.' }) +export const uploadLimiter = limiter({ windowMs: 15 * MINUTE, limit: 60, message: 'Too many uploads. Wait a few minutes and try again.' }) diff --git a/backend/src/middlewares/requireRole.js b/backend/src/middlewares/requireRole.js new file mode 100644 index 0000000..7ab742e --- /dev/null +++ b/backend/src/middlewares/requireRole.js @@ -0,0 +1,4 @@ +import { forbidden } from '../utils/AppError.js' + +/** Use after `authenticate`. */ +export const requireRole = (...roles) => (req, res, next) => (roles.includes(req.user?.role) ? next() : next(forbidden())) diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js new file mode 100644 index 0000000..2246944 --- /dev/null +++ b/backend/src/middlewares/upload.js @@ -0,0 +1,31 @@ +import { randomUUID } from 'node:crypto' +import multer from 'multer' +import { env } from '../config/env.js' +import { storageService } from '../services/storage/index.js' +import { validationError } from '../utils/AppError.js' + +// Uploads land in a temp folder first. Services check the file's real type, then hand it to storage. +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, storageService.tempDir), + filename: (req, file, cb) => cb(null, randomUUID()), +}) + +const allow = (rules) => (req, file, cb) => { + const prefix = rules[file.fieldname] + if (!prefix) return cb(validationError({ [file.fieldname]: 'Unexpected file.' })) + if (!file.mimetype.startsWith(prefix)) return cb(validationError({ [file.fieldname]: `Upload a ${prefix.replace('/', '')} file.` })) + return cb(null, true) +} + +const build = ({ maxMb, rules, files }) => multer({ + storage, + fileFilter: allow(rules), + limits: { fileSize: maxMb * 1024 * 1024, files, fields: 20, fieldSize: 10 * 1024 }, +}) + +/** Base video (+ optional poster image) for the admin library. */ +export const videoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/', poster: 'image/' }, files: 2 }) + .fields([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }]) + +/** A contributor's recording. */ +export const recordingUpload = build({ maxMb: env.MAX_RECORDING_UPLOAD_MB, rules: { recording: 'video/' }, files: 1 }).single('recording') diff --git a/backend/src/middlewares/validate.js b/backend/src/middlewares/validate.js new file mode 100644 index 0000000..af3de4e --- /dev/null +++ b/backend/src/middlewares/validate.js @@ -0,0 +1,28 @@ +import { validationError } from '../utils/AppError.js' + +/** Turns Zod issues into { fieldName: "message" }, keeping the first message per field. */ +export function issuesToFields(issues) { + const fields = {} + for (const issue of issues) { + const name = issue.path.join('.') || '_' + if (!fields[name]) fields[name] = issue.message + } + return fields +} + +/** + * validate({ body, params, query }) parses each part with its Zod schema. + * On success the parsed (trimmed, coerced, unknown-keys-stripped) values replace the originals, + * so downstream code only ever sees clean input. + */ +export const validate = (schemas) => (req, res, next) => { + const fields = {} + for (const part of ['params', 'query', 'body']) { + if (!schemas[part]) continue + const result = schemas[part].safeParse(req[part] ?? {}) + if (result.success) req[part] = result.data + else Object.assign(fields, issuesToFields(result.error.issues)) + } + if (Object.keys(fields).length) return next(validationError(fields)) + return next() +} diff --git a/backend/src/middlewares/validators/authSchemas.js b/backend/src/middlewares/validators/authSchemas.js new file mode 100644 index 0000000..8619394 --- /dev/null +++ b/backend/src/middlewares/validators/authSchemas.js @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { emailField } from './common.js' + +const name = (label) => z.string({ error: `Enter your ${label}.` }).trim().min(1, `Enter your ${label}.`).max(60, `Keep your ${label} under 60 characters.`) + +export const signupSchema = z.object({ + firstName: name('first name'), + surname: name('surname'), + email: emailField, + // bcrypt only reads the first 72 bytes, so longer passwords would silently be truncated. + password: z.string({ error: 'Create a password.' }).min(8, 'Use at least 8 characters.').max(72, 'Use 72 characters or fewer.'), + confirmPassword: z.string().optional(), +}).refine((data) => data.confirmPassword === undefined || data.confirmPassword === data.password, { path: ['confirmPassword'], message: 'Passwords do not match.' }) + +export const loginSchema = z.object({ + email: emailField, + password: z.string({ error: 'Enter your password.' }).min(1, 'Enter your password.').max(200), +}) diff --git a/backend/src/middlewares/validators/categorySchemas.js b/backend/src/middlewares/validators/categorySchemas.js new file mode 100644 index 0000000..5b16cc2 --- /dev/null +++ b/backend/src/middlewares/validators/categorySchemas.js @@ -0,0 +1,10 @@ +import { z } from 'zod' +import { TONES } from '../../config/constants.js' + +const label = z.string({ error: 'Give the category a name.' }).trim().min(1, 'Give the category a name.').max(60, 'Keep the name under 60 characters.') +const copy = z.string().trim().max(240, 'Keep the description under 240 characters.') +const tone = z.enum(TONES, { error: `Colour must be one of: ${TONES.join(', ')}.` }) + +export const createCategorySchema = z.object({ label, copy: copy.default(''), tone: tone.default(TONES[0]) }) +export const updateCategorySchema = z.object({ label: label.optional(), copy: copy.optional(), tone: tone.optional() }) + .refine((data) => Object.keys(data).length > 0, { message: 'Send at least one field to change.' }) diff --git a/backend/src/middlewares/validators/common.js b/backend/src/middlewares/validators/common.js new file mode 100644 index 0000000..680f5e5 --- /dev/null +++ b/backend/src/middlewares/validators/common.js @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const objectId = (label = 'id') => z.string().regex(/^[a-f\d]{24}$/i, `That ${label} is not valid.`) +export const idParams = z.object({ id: objectId() }) + +export const emailField = z.string({ error: 'Enter your email address.' }).trim().toLowerCase().pipe(z.email('Enter a valid email address, like you@example.com.')) + +// Multipart forms send everything as strings, so booleans arrive as "true"/"false". +export const booleanField = z.preprocess((value) => (value === 'true' ? true : value === 'false' ? false : value), z.boolean()) diff --git a/backend/src/middlewares/validators/miscSchemas.js b/backend/src/middlewares/validators/miscSchemas.js new file mode 100644 index 0000000..af09c9e --- /dev/null +++ b/backend/src/middlewares/validators/miscSchemas.js @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { emailField } from './common.js' + +export const contactSchema = z.object({ + name: z.string({ error: 'Enter your name.' }).trim().min(1, 'Enter your name.').max(100, 'Keep your name under 100 characters.'), + email: emailField, + message: z.string({ error: 'Write a message.' }).trim().min(1, 'Write a message.').max(2000, 'Keep your message under 2000 characters.'), +}) + +export const statsQuerySchema = z.object({ days: z.coerce.number().int().min(7).max(90).default(14) }) + +export const limitQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) }) diff --git a/backend/src/middlewares/validators/submissionSchemas.js b/backend/src/middlewares/validators/submissionSchemas.js new file mode 100644 index 0000000..25820ec --- /dev/null +++ b/backend/src/middlewares/validators/submissionSchemas.js @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { booleanField, objectId } from './common.js' + +const seconds = (label) => z.coerce.number({ error: `${label} must be a number of seconds.` }).min(0, `${label} cannot be negative.`) + +export const createSubmissionSchema = z.object({ + videoId: objectId('video'), + trimStart: seconds('Trim start'), + trimEnd: seconds('Trim end'), + mirrored: booleanField.default(false), + duration: seconds('Duration'), +}) diff --git a/backend/src/middlewares/validators/userSchemas.js b/backend/src/middlewares/validators/userSchemas.js new file mode 100644 index 0000000..a76e844 --- /dev/null +++ b/backend/src/middlewares/validators/userSchemas.js @@ -0,0 +1,26 @@ +import { z } from 'zod' + +const GITHUB_USER = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i +const LINKEDIN_URL = /^(?:https?:\/\/)?(?:[\w]+\.)?linkedin\.com\/in\/([\w\-%]{3,100})\/?$/i +const LINKEDIN_SLUG = /^[\w\-%]{3,100}$/ + +// Same rules as the frontend: accept a handle or a profile link, store one canonical form, null disconnects. +const github = z.string().trim().nullable().transform((value, ctx) => { + if (!value) return null + const username = value.replace(/^https?:\/\/(www\.)?github\.com\//i, '').replace(/^@/, '').replace(/\/+$/, '') + if (GITHUB_USER.test(username)) return username + ctx.addIssue({ code: 'custom', message: 'Enter a GitHub username or a github.com profile link.' }) + return z.NEVER +}) + +const linkedin = z.string().trim().nullable().transform((value, ctx) => { + if (!value) return null + const slug = value.match(LINKEDIN_URL)?.[1] ?? value + if (LINKEDIN_SLUG.test(slug)) return `https://www.linkedin.com/in/${slug}` + ctx.addIssue({ code: 'custom', message: 'Enter a LinkedIn profile link, like linkedin.com/in/your-name.' }) + return z.NEVER +}) + +export const updateMeSchema = z.object({ + connections: z.object({ github: github.optional(), linkedin: linkedin.optional() }), +}) diff --git a/backend/src/middlewares/validators/videoSchemas.js b/backend/src/middlewares/validators/videoSchemas.js new file mode 100644 index 0000000..f17a5df --- /dev/null +++ b/backend/src/middlewares/validators/videoSchemas.js @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { VIDEO_STATUS, VIDEO_STATUS_LIST } from '../../config/constants.js' +import { objectId } from './common.js' + +const title = z.string({ error: 'Give the video a title.' }).trim().min(1, 'Give the video a title.').max(120, 'Keep the title under 120 characters.') +const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDEO_STATUS_LIST.join(', ')}.` }) +// An empty string (from a form's "Unassigned" option) means no category. +const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable()) + +export const createVideoSchema = z.object({ + title, + status: status.default(VIDEO_STATUS.PUBLISHED), + categoryId: categoryId.optional().default(null), + durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), +}) + +export const updateVideoSchema = z.object({ + title: title.optional(), + status: status.optional(), + categoryId: categoryId.optional(), + order: z.coerce.number().int().min(0).optional(), +}).refine((data) => Object.keys(data).length > 0, { message: 'Send at least one field to change.' }) diff --git a/backend/src/middlewares/verifyOrigin.js b/backend/src/middlewares/verifyOrigin.js new file mode 100644 index 0000000..d7b32d7 --- /dev/null +++ b/backend/src/middlewares/verifyOrigin.js @@ -0,0 +1,16 @@ +import { env } from '../config/env.js' +import { forbidden } from '../utils/AppError.js' + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +/** + * CSRF defence for cookie auth, on top of SameSite: browsers always send Origin on cross-site + * writes, so a state-changing request from an origin we do not trust is refused. + * Requests with no Origin header (curl, server-to-server, tests) are not browser CSRF vectors. + */ +export function verifyOrigin(req, res, next) { + if (SAFE_METHODS.has(req.method)) return next() + const origin = req.headers.origin + if (!origin || env.clientOrigins.includes(origin)) return next() + return next(forbidden('This request came from an origin that is not allowed.')) +} diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js new file mode 100644 index 0000000..36d2fa9 --- /dev/null +++ b/backend/src/models/Category.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' +import { TONES } from '../config/constants.js' +import { jsonOptions } from '../utils/mongoose.js' + +const categorySchema = new mongoose.Schema({ + label: { type: String, required: true, trim: true, maxlength: 60 }, + // Lower-cased copy of the label, so "Travel" and "travel" cannot both exist. + labelKey: { type: String, required: true, unique: true }, + copy: { type: String, default: '', trim: true, maxlength: 240 }, + tone: { type: String, enum: TONES, default: TONES[0] }, +}, { + timestamps: true, + toJSON: jsonOptions((doc, ret) => { delete ret.labelKey; delete ret.updatedAt; delete ret.createdAt; return ret }), +}) + +categorySchema.pre('validate', function setLabelKey() { + if (this.label) this.labelKey = this.label.trim().toLowerCase() +}) + +export const Category = mongoose.model('Category', categorySchema) diff --git a/backend/src/models/ContactMessage.js b/backend/src/models/ContactMessage.js new file mode 100644 index 0000000..229e71e --- /dev/null +++ b/backend/src/models/ContactMessage.js @@ -0,0 +1,12 @@ +import mongoose from 'mongoose' +import { jsonOptions } from '../utils/mongoose.js' + +const contactMessageSchema = new mongoose.Schema({ + name: { type: String, required: true, trim: true, maxlength: 100 }, + email: { type: String, required: true, lowercase: true, trim: true, maxlength: 254 }, + message: { type: String, required: true, trim: true, maxlength: 2000 }, +}, { timestamps: true, toJSON: jsonOptions((doc, ret) => { delete ret.updatedAt; return ret }) }) + +contactMessageSchema.index({ createdAt: -1 }) + +export const ContactMessage = mongoose.model('ContactMessage', contactMessageSchema) diff --git a/backend/src/models/Submission.js b/backend/src/models/Submission.js new file mode 100644 index 0000000..3be4846 --- /dev/null +++ b/backend/src/models/Submission.js @@ -0,0 +1,33 @@ +import mongoose from 'mongoose' +import { jsonOptions } from '../utils/mongoose.js' +import { storedFileSchema } from './storedFile.js' + +const submissionSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + video: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, + trimStart: { type: Number, required: true, min: 0 }, + trimEnd: { type: Number, required: true, min: 0 }, + mirrored: { type: Boolean, default: false }, + duration: { type: Number, required: true, min: 0 }, + recording: { type: storedFileSchema, required: true }, +}, { + timestamps: true, + toJSON: jsonOptions((doc, ret) => { + ret.userId = String(ret.user) + ret.videoId = String(ret.video) + ret.submittedAt = ret.createdAt + ret.size = ret.recording?.size ?? 0 + ret.mimeType = ret.recording?.mimeType + for (const key of ['user', 'video', 'recording', 'createdAt', 'updatedAt']) delete ret[key] + return ret + }), +}) + +// Not unique: a contributor may submit the same video more than once over time (see +// SubmissionCooldown for what actually stops two submissions from landing back to back). +// Compound, not just on (user, video), because the cooldown check's only query is +// "this user's most recent submission for this video", sorted by time. +submissionSchema.index({ user: 1, video: 1, createdAt: -1 }) +submissionSchema.index({ createdAt: -1 }) + +export const Submission = mongoose.model('Submission', submissionSchema) diff --git a/backend/src/models/SubmissionCooldown.js b/backend/src/models/SubmissionCooldown.js new file mode 100644 index 0000000..8063eac --- /dev/null +++ b/backend/src/models/SubmissionCooldown.js @@ -0,0 +1,19 @@ +import mongoose from 'mongoose' + +/** + * One document per (user, video) pair, tracking when that pair is next allowed to submit. + * This is what actually enforces the cooldown, atomically: see + * repositories/submissionRepo.js#claimCooldown for how the unique index below is used as a + * compare-and-swap so two simultaneous submissions for the same video cannot both win, the + * same trick the old one-submission-ever rule used, just against a time window instead of + * forever. + */ +const submissionCooldownSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + video: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, + lockedUntil: { type: Date, required: true }, +}, { versionKey: false }) + +submissionCooldownSchema.index({ user: 1, video: 1 }, { unique: true }) + +export const SubmissionCooldown = mongoose.model('SubmissionCooldown', submissionCooldownSchema) diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 0000000..cad2e4a --- /dev/null +++ b/backend/src/models/User.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' +import { ROLES, ROLE_LIST } from '../config/constants.js' +import { jsonOptions } from '../utils/mongoose.js' + +const userSchema = new mongoose.Schema({ + email: { type: String, required: true, unique: true, lowercase: true, trim: true, maxlength: 254 }, + firstName: { type: String, required: true, trim: true, maxlength: 60 }, + surname: { type: String, required: true, trim: true, maxlength: 60 }, + passwordHash: { type: String, required: true, select: false }, + role: { type: String, enum: ROLE_LIST, default: ROLES.USER }, + connections: { + github: { type: String, default: null }, + linkedin: { type: String, default: null }, + }, +}, { + timestamps: true, + toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; return ret }), +}) + +export const User = mongoose.model('User', userSchema) diff --git a/backend/src/models/Video.js b/backend/src/models/Video.js new file mode 100644 index 0000000..114d342 --- /dev/null +++ b/backend/src/models/Video.js @@ -0,0 +1,31 @@ +import mongoose from 'mongoose' +import { API_PREFIX, VIDEO_STATUS, VIDEO_STATUS_LIST } from '../config/constants.js' +import { jsonOptions } from '../utils/mongoose.js' +import { storedFileSchema } from './storedFile.js' + +const videoSchema = new mongoose.Schema({ + title: { type: String, required: true, trim: true, maxlength: 120 }, + status: { type: String, enum: VIDEO_STATUS_LIST, default: VIDEO_STATUS.PUBLISHED }, + category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null }, + order: { type: Number, default: 1, min: 0 }, + durationSec: { type: Number, default: 0, min: 0 }, + videoFile: { type: storedFileSchema, required: true }, + posterFile: { type: storedFileSchema, default: null }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, +}, { + timestamps: true, + // The API exposes playable URLs, never storage keys. + toJSON: jsonOptions((doc, ret) => { + const base = `${API_PREFIX}/videos/${ret.id}` + ret.categoryId = ret.category ? String(ret.category) : null + ret.videoUrl = `${base}/file` + ret.poster = ret.posterFile ? `${base}/poster` : null + ret.size = ret.videoFile?.size ?? 0 + for (const key of ['category', 'videoFile', 'posterFile', 'createdBy', 'updatedAt']) delete ret[key] + return ret + }), +}) + +videoSchema.index({ status: 1, category: 1, order: 1 }) + +export const Video = mongoose.model('Video', videoSchema) diff --git a/backend/src/models/storedFile.js b/backend/src/models/storedFile.js new file mode 100644 index 0000000..4aad584 --- /dev/null +++ b/backend/src/models/storedFile.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose' + +/** + * Pointer to a file held by a storage driver. Only this is stored in MongoDB, never the bytes. + * `driver` + `key` are enough to find the file again, even after the default driver changes. + */ +export const storedFileSchema = new mongoose.Schema({ + driver: { type: String, required: true }, + key: { type: String, required: true }, + mimeType: { type: String, required: true }, + size: { type: Number, required: true, min: 0 }, + originalName: { type: String, default: '' }, +}, { _id: false }) diff --git a/backend/src/repositories/categoryRepo.js b/backend/src/repositories/categoryRepo.js new file mode 100644 index 0000000..46d8e11 --- /dev/null +++ b/backend/src/repositories/categoryRepo.js @@ -0,0 +1,11 @@ +import { Category } from '../models/Category.js' + +export const categoryRepo = { + list: () => Category.find().sort({ createdAt: 1 }), + findById: (id) => Category.findById(id), + findByLabel: (label) => Category.findOne({ labelKey: label.trim().toLowerCase() }), + create: (data) => Category.create(data), + save: (doc) => doc.save(), // save() re-runs validation and the labelKey hook + remove: (id) => Category.deleteOne({ _id: id }), + count: () => Category.countDocuments(), +} diff --git a/backend/src/repositories/messageRepo.js b/backend/src/repositories/messageRepo.js new file mode 100644 index 0000000..ea3e764 --- /dev/null +++ b/backend/src/repositories/messageRepo.js @@ -0,0 +1,6 @@ +import { ContactMessage } from '../models/ContactMessage.js' + +export const messageRepo = { + create: (data) => ContactMessage.create(data), + list: (limit) => ContactMessage.find().sort({ createdAt: -1 }).limit(limit), +} diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js new file mode 100644 index 0000000..02de858 --- /dev/null +++ b/backend/src/repositories/submissionRepo.js @@ -0,0 +1,50 @@ +import { Submission } from '../models/Submission.js' +import { SubmissionCooldown } from '../models/SubmissionCooldown.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' + +export const submissionRepo = { + create: (data) => Submission.create(data), + findById: (id) => Submission.findById(id), + list: ({ userId } = {}) => Submission.find(userId ? { user: userId } : {}).sort({ createdAt: -1 }), + recent: (limit) => Submission.find().sort({ createdAt: -1 }).limit(limit), + count: () => Submission.countDocuments(), + // Just the timestamps, for bucketing into days. + createdSince: (date) => Submission.find({ createdAt: { $gte: date } }).select('createdAt').lean(), + countsByVideo: () => Submission.aggregate([{ $group: { _id: '$video', count: { $sum: 1 } } }]), + + /** + * Atomically tries to start a cooldown window for (user, video). Returns true if this call + * won (no other submission for this pair is currently inside its cooldown), false if one + * already is. Race-safe under concurrent requests: the conditional filter plus the unique + * index on SubmissionCooldown make this a compare-and-swap, not a read-then-write. + * - No cooldown doc yet -> upsert inserts one. Wins. + * - Doc exists, its window already elapsed (lockedUntil <= now) -> matches, gets updated + * to a fresh window. Wins. + * - Doc exists, still inside its window -> filter does not match, so the upsert instead + * tries to INSERT a new one (user, video) and collides with the unique index. Loses. + */ + async claimCooldown(user, video, cooldownMs) { + const now = new Date() + try { + await SubmissionCooldown.findOneAndUpdate( + { user, video, lockedUntil: { $lte: now } }, + { $set: { lockedUntil: new Date(now.getTime() + cooldownMs) } }, + { upsert: true }, + ) + return true + } catch (error) { + if (isDuplicateKeyError(error)) return false + throw error + } + }, + + /** The remaining wait, in ms (0 if none), without claiming anything. Used to build the error message. */ + async cooldownRemaining(user, video) { + const doc = await SubmissionCooldown.findOne({ user, video }).select('lockedUntil').lean() + if (!doc) return 0 + return Math.max(0, doc.lockedUntil.getTime() - Date.now()) + }, + + /** Frees a claimed cooldown, so a failed submission does not lock someone out for nothing. */ + releaseCooldown: (user, video) => SubmissionCooldown.deleteOne({ user, video }), +} diff --git a/backend/src/repositories/userRepo.js b/backend/src/repositories/userRepo.js new file mode 100644 index 0000000..5bee158 --- /dev/null +++ b/backend/src/repositories/userRepo.js @@ -0,0 +1,14 @@ +import { User } from '../models/User.js' + +export const userRepo = { + findById: (id) => User.findById(id), + findByEmail: (email, { withPassword = false } = {}) => { + const query = User.findOne({ email: email.trim().toLowerCase() }) + return withPassword ? query.select('+passwordHash') : query + }, + findManyByIds: (ids) => User.find({ _id: { $in: ids } }), + create: (data) => User.create(data), + save: (doc) => doc.save(), + list: ({ role } = {}) => User.find(role ? { role } : {}).sort({ createdAt: 1 }), + countByRole: (role) => User.countDocuments({ role }), +} diff --git a/backend/src/repositories/videoRepo.js b/backend/src/repositories/videoRepo.js new file mode 100644 index 0000000..b641cc3 --- /dev/null +++ b/backend/src/repositories/videoRepo.js @@ -0,0 +1,19 @@ +import { VIDEO_STATUS } from '../config/constants.js' +import { Video } from '../models/Video.js' + +export const videoRepo = { + // Contributors see published videos that belong to a category. Admins see everything. + listVisible: ({ isAdmin }) => Video.find(isAdmin ? {} : { status: VIDEO_STATUS.PUBLISHED, category: { $ne: null } }).sort({ order: 1, createdAt: 1 }), + findById: (id) => Video.findById(id), + findManyByIds: (ids) => Video.find({ _id: { $in: ids } }), + create: (data) => Video.create(data), + save: (doc) => doc.save(), + remove: (id) => Video.deleteOne({ _id: id }), + async maxOrder(categoryId) { + const last = await Video.findOne({ category: categoryId }).sort({ order: -1 }).select('order') + return last?.order ?? 0 + }, + unassignCategory: (categoryId) => Video.updateMany({ category: categoryId }, { $set: { category: null } }), + count: (filter = {}) => Video.countDocuments(filter), + categoryOfEach: () => Video.find().select('category'), +} diff --git a/backend/src/routes/adminRoutes.js b/backend/src/routes/adminRoutes.js new file mode 100644 index 0000000..ef507e7 --- /dev/null +++ b/backend/src/routes/adminRoutes.js @@ -0,0 +1,18 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { adminController } from '../controllers/adminController.js' +import { contactController } from '../controllers/contactController.js' +import { userController } from '../controllers/userController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { requireRole } from '../middlewares/requireRole.js' +import { validate } from '../middlewares/validate.js' +import { limitQuerySchema, statsQuerySchema } from '../middlewares/validators/miscSchemas.js' + +const router = Router() + +router.use(authenticate, requireRole(ROLES.ADMIN)) +router.get('/users', userController.list) +router.get('/stats', validate({ query: statsQuerySchema }), adminController.stats) +router.get('/messages', validate({ query: limitQuerySchema }), contactController.list) + +export default router diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..504c2ff --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,15 @@ +import { Router } from 'express' +import { authController } from '../controllers/authController.js' +import { optionalAuthenticate } from '../middlewares/authenticate.js' +import { authLimiter } from '../middlewares/rateLimiter.js' +import { validate } from '../middlewares/validate.js' +import { loginSchema, signupSchema } from '../middlewares/validators/authSchemas.js' + +const router = Router() + +router.post('/signup', authLimiter, validate({ body: signupSchema }), authController.signup) +router.post('/login', authLimiter, validate({ body: loginSchema }), authController.login) +router.post('/logout', authController.logout) +router.get('/session', optionalAuthenticate, authController.session) + +export default router diff --git a/backend/src/routes/categoryRoutes.js b/backend/src/routes/categoryRoutes.js new file mode 100644 index 0000000..09e5b82 --- /dev/null +++ b/backend/src/routes/categoryRoutes.js @@ -0,0 +1,18 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { categoryController } from '../controllers/categoryController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { requireRole } from '../middlewares/requireRole.js' +import { validate } from '../middlewares/validate.js' +import { createCategorySchema, updateCategorySchema } from '../middlewares/validators/categorySchemas.js' +import { idParams } from '../middlewares/validators/common.js' + +const router = Router() +const adminOnly = [authenticate, requireRole(ROLES.ADMIN)] + +router.get('/', categoryController.list) // public: the landing page shows how many paths exist +router.post('/', ...adminOnly, validate({ body: createCategorySchema }), categoryController.create) +router.patch('/:id', ...adminOnly, validate({ params: idParams, body: updateCategorySchema }), categoryController.update) +router.delete('/:id', ...adminOnly, validate({ params: idParams }), categoryController.remove) + +export default router diff --git a/backend/src/routes/contactRoutes.js b/backend/src/routes/contactRoutes.js new file mode 100644 index 0000000..3865d10 --- /dev/null +++ b/backend/src/routes/contactRoutes.js @@ -0,0 +1,11 @@ +import { Router } from 'express' +import { contactController } from '../controllers/contactController.js' +import { contactLimiter } from '../middlewares/rateLimiter.js' +import { validate } from '../middlewares/validate.js' +import { contactSchema } from '../middlewares/validators/miscSchemas.js' + +const router = Router() + +router.post('/', contactLimiter, validate({ body: contactSchema }), contactController.send) + +export default router diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..c1b3a00 --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,31 @@ +import { Router } from 'express' +import swaggerUi from 'swagger-ui-express' +import { isDbReady } from '../config/db.js' +import { openapi } from '../docs/openapi.js' +import adminRoutes from './adminRoutes.js' +import authRoutes from './authRoutes.js' +import categoryRoutes from './categoryRoutes.js' +import contactRoutes from './contactRoutes.js' +import submissionRoutes from './submissionRoutes.js' +import userRoutes from './userRoutes.js' +import videoRoutes from './videoRoutes.js' + +const router = Router() + +router.get('/health', (req, res) => { + const db = isDbReady() + res.status(db ? 200 : 503).json({ data: { status: db ? 'ok' : 'degraded', database: db ? 'connected' : 'disconnected', uptime: Math.round(process.uptime()) } }) +}) + +router.get('/docs.json', (req, res) => res.json(openapi)) +router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) + +router.use('/auth', authRoutes) +router.use('/users', userRoutes) +router.use('/categories', categoryRoutes) +router.use('/videos', videoRoutes) +router.use('/submissions', submissionRoutes) +router.use('/contact', contactRoutes) +router.use('/admin', adminRoutes) + +export default router diff --git a/backend/src/routes/submissionRoutes.js b/backend/src/routes/submissionRoutes.js new file mode 100644 index 0000000..673c50f --- /dev/null +++ b/backend/src/routes/submissionRoutes.js @@ -0,0 +1,21 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { submissionController } from '../controllers/submissionController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { uploadLimiter } from '../middlewares/rateLimiter.js' +import { requireRole } from '../middlewares/requireRole.js' +import { recordingUpload } from '../middlewares/upload.js' +import { validate } from '../middlewares/validate.js' +import { idParams } from '../middlewares/validators/common.js' +import { createSubmissionSchema } from '../middlewares/validators/submissionSchemas.js' + +const router = Router() + +router.use(authenticate) +router.get('/', submissionController.list) +router.post('/', uploadLimiter, recordingUpload, validate({ body: createSubmissionSchema }), submissionController.create) + +// No PATCH or DELETE exists on purpose: a submission cannot be changed or taken back. +router.get('/:id/recording', requireRole(ROLES.ADMIN), validate({ params: idParams }), submissionController.recording) + +export default router diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js new file mode 100644 index 0000000..56e2390 --- /dev/null +++ b/backend/src/routes/userRoutes.js @@ -0,0 +1,13 @@ +import { Router } from 'express' +import { userController } from '../controllers/userController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { validate } from '../middlewares/validate.js' +import { updateMeSchema } from '../middlewares/validators/userSchemas.js' + +const router = Router() + +router.use(authenticate) +router.get('/me', userController.me) +router.patch('/me', validate({ body: updateMeSchema }), userController.updateMe) + +export default router diff --git a/backend/src/routes/videoRoutes.js b/backend/src/routes/videoRoutes.js new file mode 100644 index 0000000..9d0f6a9 --- /dev/null +++ b/backend/src/routes/videoRoutes.js @@ -0,0 +1,29 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { videoController } from '../controllers/videoController.js' +import { authenticate, optionalAuthenticate } from '../middlewares/authenticate.js' +import { requireRole } from '../middlewares/requireRole.js' +import { uploadLimiter } from '../middlewares/rateLimiter.js' +import { validate } from '../middlewares/validate.js' +import { idParams } from '../middlewares/validators/common.js' +import { createVideoSchema, updateVideoSchema } from '../middlewares/validators/videoSchemas.js' +import { videoUpload } from '../middlewares/upload.js' + +const router = Router() +const adminOnly = [authenticate, requireRole(ROLES.ADMIN)] + +// Metadata and posters: anonymous visitors see published videos only, admins see everything. +router.get('/', optionalAuthenticate, videoController.list) +router.get('/:id', optionalAuthenticate, validate({ params: idParams }), videoController.get) +router.get('/:id/poster', optionalAuthenticate, validate({ params: idParams }), videoController.poster) + +// Published video files are public reference media; unpublished/admin media still requires visibility checks in the service. +router.get('/:id/file', optionalAuthenticate, validate({ params: idParams }), videoController.file) +router.get('/:id/file', authenticate, validate({ params: idParams }), videoController.file) + +// Order matters: log in, check role, then accept the upload, then validate the text fields that came with it. +router.post('/', ...adminOnly, uploadLimiter, videoUpload, validate({ body: createVideoSchema }), videoController.create) +router.patch('/:id', ...adminOnly, validate({ params: idParams, body: updateVideoSchema }), videoController.update) +router.delete('/:id', ...adminOnly, validate({ params: idParams }), videoController.remove) + +export default router diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js new file mode 100644 index 0000000..1b37bbb --- /dev/null +++ b/backend/src/services/authService.js @@ -0,0 +1,50 @@ +import bcrypt from 'bcrypt' +import { ROLES } from '../config/constants.js' +import { env } from '../config/env.js' +import { userRepo } from '../repositories/userRepo.js' +import { conflict, unauthorized } from '../utils/AppError.js' +import { logger } from '../utils/logger.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' +import { signToken } from '../utils/tokens.js' + +// Compared against when the email is unknown, so "no such user" takes as long as "wrong password". +const DUMMY_HASH = bcrypt.hashSync('signpak-timing-guard', env.BCRYPT_ROUNDS) + +const emailTaken = () => conflict('An account with this email already exists. Log in instead.', { email: 'An account with this email already exists.' }) + +export const authService = { + async register({ email, firstName, surname, password }) { + if (await userRepo.findByEmail(email)) throw emailTaken() + const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) + try { + // `role` is never read from the request: everyone who signs up is a contributor. + const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER }) + return { user, token: signToken(user.id) } + } catch (error) { + if (isDuplicateKeyError(error)) throw emailTaken() // two signups racing for the same email + throw error + } + }, + + async login({ email, password }) { + const user = await userRepo.findByEmail(email, { withPassword: true }) + const matches = await bcrypt.compare(password, user?.passwordHash || DUMMY_HASH) + if (!user || !matches) throw unauthorized('That email and password do not match. Check them and try again.') + return { user, token: signToken(user.id) } + }, + + /** Admin accounts come from environment variables, never from the signup form. Safe to run on every boot. */ + async ensureAdminFromEnv() { + const existing = await userRepo.findByEmail(env.ADMIN_EMAIL) + if (!existing) { + const passwordHash = await bcrypt.hash(env.ADMIN_PASSWORD, env.BCRYPT_ROUNDS) + await userRepo.create({ email: env.ADMIN_EMAIL, firstName: env.ADMIN_FIRST_NAME, surname: env.ADMIN_SURNAME, passwordHash, role: ROLES.ADMIN }) + logger.info({ email: env.ADMIN_EMAIL }, 'Admin account created from environment') + } else if (existing.role !== ROLES.ADMIN) { + existing.role = ROLES.ADMIN + await userRepo.save(existing) + logger.info({ email: env.ADMIN_EMAIL }, 'Existing account promoted to admin from environment') + } + // An existing admin's password is intentionally left alone; changing ADMIN_PASSWORD later does not reset it. + }, +} diff --git a/backend/src/services/categoryService.js b/backend/src/services/categoryService.js new file mode 100644 index 0000000..52ea08b --- /dev/null +++ b/backend/src/services/categoryService.js @@ -0,0 +1,44 @@ +import { categoryRepo } from '../repositories/categoryRepo.js' +import { videoRepo } from '../repositories/videoRepo.js' +import { conflict, notFound } from '../utils/AppError.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' + +const nameTaken = () => conflict('A category with that name already exists.', { label: 'A category with that name already exists.' }) + +export const categoryService = { + list: () => categoryRepo.list(), + + async create({ label, copy, tone }) { + if (await categoryRepo.findByLabel(label)) throw nameTaken() + try { + return await categoryRepo.create({ label, copy, tone }) + } catch (error) { + if (isDuplicateKeyError(error)) throw nameTaken() + throw error + } + }, + + async update(id, patch) { + const category = await categoryRepo.findById(id) + if (!category) throw notFound('That category does not exist.') + if (patch.label) { + const other = await categoryRepo.findByLabel(patch.label) + if (other && other.id !== category.id) throw nameTaken() + } + category.set(patch) + try { + return await categoryRepo.save(category) + } catch (error) { + if (isDuplicateKeyError(error)) throw nameTaken() + throw error + } + }, + + /** Deleting a category keeps its videos; they become unassigned (and hidden from contributors). */ + async remove(id) { + const category = await categoryRepo.findById(id) + if (!category) throw notFound('That category does not exist.') + await videoRepo.unassignCategory(category.id) + await categoryRepo.remove(category.id) + }, +} diff --git a/backend/src/services/contactService.js b/backend/src/services/contactService.js new file mode 100644 index 0000000..77b2a97 --- /dev/null +++ b/backend/src/services/contactService.js @@ -0,0 +1,6 @@ +import { messageRepo } from '../repositories/messageRepo.js' + +export const contactService = { + send: ({ name, email, message }) => messageRepo.create({ name, email, message }), + list: (limit) => messageRepo.list(limit), +} diff --git a/backend/src/services/statsService.js b/backend/src/services/statsService.js new file mode 100644 index 0000000..e89695d --- /dev/null +++ b/backend/src/services/statsService.js @@ -0,0 +1,72 @@ +import { ROLES, VIDEO_STATUS } from '../config/constants.js' +import { categoryRepo } from '../repositories/categoryRepo.js' +import { submissionRepo } from '../repositories/submissionRepo.js' +import { userRepo } from '../repositories/userRepo.js' +import { videoRepo } from '../repositories/videoRepo.js' + +const DAY_MS = 24 * 60 * 60 * 1000 +const startOfUtcDay = (time) => { const d = new Date(time); d.setUTCHours(0, 0, 0, 0); return d } +const byId = (docs) => new Map(docs.map((doc) => [doc.id, doc])) + +export const statsService = { + /** Numbers for the admin dashboard. Days are UTC days. */ + async overview({ days }) { + const start = startOfUtcDay(Date.now() - (days - 1) * DAY_MS) + const [contributors, videos, published, submissions, categories, createdDates, perVideo, videoCategories, recent] = await Promise.all([ + userRepo.countByRole(ROLES.USER), + videoRepo.count(), + videoRepo.count({ status: VIDEO_STATUS.PUBLISHED }), + submissionRepo.count(), + categoryRepo.list(), + submissionRepo.createdSince(start), + submissionRepo.countsByVideo(), + videoRepo.categoryOfEach(), + submissionRepo.recent(6), + ]) + + const perDay = new Map() + for (const { createdAt } of createdDates) { + const key = createdAt.toISOString().slice(0, 10) + perDay.set(key, (perDay.get(key) || 0) + 1) + } + const activity = Array.from({ length: days }, (_, index) => { + const date = new Date(start.getTime() + index * DAY_MS).toISOString().slice(0, 10) + return { date, count: perDay.get(date) || 0 } + }) + + const categoryOfVideo = new Map(videoCategories.map((video) => [video.id, video.category ? String(video.category) : null])) + const perCategory = new Map() + for (const { _id, count } of perVideo) { + const categoryId = categoryOfVideo.get(String(_id)) + if (categoryId) perCategory.set(categoryId, (perCategory.get(categoryId) || 0) + count) + } + const byCategory = categories.map((category) => ({ categoryId: category.id, label: category.label, tone: category.tone, count: perCategory.get(category.id) || 0 })) + + const [users, recentVideos] = await Promise.all([ + userRepo.findManyByIds(recent.map((item) => item.user)), + videoRepo.findManyByIds(recent.map((item) => item.video)), + ]) + const userMap = byId(users) + const videoMap = byId(recentVideos) + const categoryMap = byId(categories) + const recentSubmissions = recent.map((item) => { + const contributor = userMap.get(String(item.user)) + const video = videoMap.get(String(item.video)) + const category = video?.category && categoryMap.get(String(video.category)) + return { + id: item.id, + submittedAt: item.createdAt, + contributor: contributor ? { id: contributor.id, name: `${contributor.firstName} ${contributor.surname}` } : null, + video: video ? { id: video.id, title: video.title } : null, + category: category ? { id: category.id, label: category.label } : null, + } + }) + + return { + totals: { contributors, videos, publishedVideos: published, submissions, categories: categories.length }, + activity, + byCategory, + recent: recentSubmissions, + } + }, +} diff --git a/backend/src/services/storage/index.js b/backend/src/services/storage/index.js new file mode 100644 index 0000000..46c6d33 --- /dev/null +++ b/backend/src/services/storage/index.js @@ -0,0 +1,39 @@ +import path from 'node:path' +import { env } from '../../config/env.js' +import { logger } from '../../utils/logger.js' +import { createLocalDriver } from './localDriver.js' + +const drivers = { local: createLocalDriver(path.resolve(env.UPLOAD_DIR)) } +const driverFor = (name) => { + if (!drivers[name]) throw new Error(`Unknown storage driver "${name}"`) + return drivers[name] +} +// New files go to the configured driver. Existing files keep using the driver recorded on them, +// so switching to Google Drive later does not orphan anything already uploaded. +const defaultDriver = () => driverFor(env.STORAGE_DRIVER) + +export const storageService = { + tempDir: drivers.local.tempDir, + + init: () => Promise.all(Object.values(drivers).map((driver) => driver.init?.())), + + /** Takes a finished temp upload and returns the reference to store on a document. */ + async save({ tempPath, originalName = '', mimeType, ext }, { folder }) { + const driver = defaultDriver() + const { key } = await driver.save({ tempPath, ext, folder }) + const { size } = await driver.stat(key) + return { driver: driver.name, key, mimeType, size, originalName: originalName.slice(0, 200) } + }, + + /** Descriptor consumed by utils/sendFile. */ + async describe(ref) { + const driver = driverFor(ref.driver) + const { size } = await driver.stat(ref.key) + return { size, mimeType: ref.mimeType, createReadStream: (range) => driver.createReadStream(ref.key, range) } + }, + + async remove(ref) { + if (!ref) return + try { await driverFor(ref.driver).remove(ref.key) } catch (error) { logger.error({ err: error, key: ref.key }, 'Could not delete stored file') } + }, +} diff --git a/backend/src/services/storage/localDriver.js b/backend/src/services/storage/localDriver.js new file mode 100644 index 0000000..e46e282 --- /dev/null +++ b/backend/src/services/storage/localDriver.js @@ -0,0 +1,62 @@ +import { createReadStream } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import { randomUUID } from 'node:crypto' + +/** + * STORAGE DRIVER CONTRACT + * Any driver (local disk now, Google Drive later) implements these four methods and nothing else: + * + * save({ tempPath, ext, folder }) -> Promise<{ key }> move/upload a finished temp file, return an opaque key + * stat(key) -> Promise<{ size }> throws if the file is gone + * createReadStream(key, { start, end }) -> Readable inclusive byte range, for HTTP Range requests + * remove(key) -> Promise idempotent + * + * Nothing above this layer knows about paths, folders or Drive file ids; they only hold { driver, key }. + * A Drive driver would map `key` to a Drive file id, upload from tempPath in save(), and use + * files.get(alt=media) with a Range header for createReadStream(). + */ +export function createLocalDriver(rootDir) { + const root = path.resolve(rootDir) + + // Keys are generated by us, but never let one escape the storage root. + const resolveKey = (key) => { + const full = path.resolve(root, key) + if (full !== root && !full.startsWith(root + path.sep)) throw new Error('Invalid storage key') + return full + } + + return { + name: 'local', + tempDir: path.join(root, 'tmp'), + + async init() { + await fs.mkdir(path.join(root, 'tmp'), { recursive: true }) + }, + + async save({ tempPath, ext = '', folder }) { + const key = `${folder}/${randomUUID()}${ext}` + const destination = resolveKey(key) + await fs.mkdir(path.dirname(destination), { recursive: true }) + try { + await fs.rename(tempPath, destination) + } catch (error) { + if (error.code !== 'EXDEV') throw error // temp dir on another disk: copy instead + await fs.copyFile(tempPath, destination) + await fs.unlink(tempPath) + } + return { key } + }, + + async stat(key) { + const { size } = await fs.stat(resolveKey(key)) + return { size } + }, + + createReadStream: (key, range) => createReadStream(resolveKey(key), range), + + async remove(key) { + await fs.unlink(resolveKey(key)).catch((error) => { if (error.code !== 'ENOENT') throw error }) + }, + } +} diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js new file mode 100644 index 0000000..d223fe4 --- /dev/null +++ b/backend/src/services/submissionService.js @@ -0,0 +1,67 @@ +import { MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js' +import { env } from '../config/env.js' +import { submissionRepo } from '../repositories/submissionRepo.js' +import { videoRepo } from '../repositories/videoRepo.js' +import { conflict, notFound, validationError } from '../utils/AppError.js' +import { sniffVideo } from '../utils/files.js' +import { logger } from '../utils/logger.js' +import { storageService } from './storage/index.js' + +const cooling = (remainingMs) => conflict(`Wait ${Math.ceil(remainingMs / 1000)}s before recording this video again.`) + +function assertTrimIsSane({ trimStart, trimEnd, duration }) { + if (duration <= 0 || duration > MAX_RECORDING_SECONDS) throw validationError({ duration: `A recording must be between 0 and ${MAX_RECORDING_SECONDS} seconds.` }) + if (trimEnd <= trimStart) throw validationError({ trimEnd: 'The trim end must come after the trim start.' }) + if (trimEnd > duration + TRIM_TOLERANCE_SECONDS) throw validationError({ trimEnd: 'The trim end is past the end of the recording.' }) +} + +export const submissionService = { + /** + * This is a data-collection tool, not a one-shot quiz: a contributor can submit a video + * more than once, since more varied takes make better training data. What is not allowed + * is two submissions for the same (user, video) back to back — SUBMISSION_COOLDOWN_MS + * apart, enforced by atomically claiming a cooldown window (repositories/submissionRepo.js + * #claimCooldown) before doing any of the expensive work (storage upload, DB write). There + * is still no update or delete for a submission once it exists: each individual recording, + * once in, stays exactly as submitted. + */ + async create(user, input, upload) { + if (!upload) throw validationError({ recording: 'Attach your recording.' }) + const kind = await sniffVideo(upload.path) + if (!kind) throw validationError({ recording: 'That does not look like an MP4, WebM or MOV video.' }) + assertTrimIsSane(input) + + const video = await videoRepo.findById(input.videoId) + if (!video || video.status !== VIDEO_STATUS.PUBLISHED || !video.category) throw notFound('That video is not available.') + + const claimed = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS) + if (!claimed) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id)) + + const recording = await storageService.save({ tempPath: upload.path, originalName: upload.originalname, mimeType: kind.mimeType, ext: kind.ext }, { folder: 'recordings' }) + try { + return await submissionRepo.create({ + user: user._id, video: video._id, trimStart: input.trimStart, trimEnd: input.trimEnd, + mirrored: input.mirrored, duration: input.duration, recording, + }) + } catch (error) { + await storageService.remove(recording) + await submissionRepo.releaseCooldown(user._id, video._id) // this attempt never landed, so it should not cost the contributor their cooldown + throw error + } + }, + + /** Contributors see their own submissions; admins see all of them. */ + list: (user) => submissionRepo.list(user.role === ROLES.ADMIN ? {} : { userId: user._id }), + + /** Admin only (enforced by the route). Contributors can never play their own submission back. */ + async getRecording(id) { + const submission = await submissionRepo.findById(id) + if (!submission) throw notFound('That submission does not exist.') + try { + return await storageService.describe(submission.recording) + } catch (error) { + logger.error({ err: error, key: submission.recording.key }, 'Recording file could not be read') + throw notFound('That recording file is missing.') + } + }, +} diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js new file mode 100644 index 0000000..79269e9 --- /dev/null +++ b/backend/src/services/userService.js @@ -0,0 +1,15 @@ +import { ROLES } from '../config/constants.js' +import { userRepo } from '../repositories/userRepo.js' + +export const userService = { + async updateConnections(user, connections) { + // Only keys that were sent change; null disconnects. + for (const key of ['github', 'linkedin']) { + if (key in connections) user.connections[key] = connections[key] + } + return userRepo.save(user) + }, + + listContributors: () => userRepo.list({ role: ROLES.USER }), + listAll: () => userRepo.list(), +} diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js new file mode 100644 index 0000000..4bffede --- /dev/null +++ b/backend/src/services/videoService.js @@ -0,0 +1,107 @@ +import { POSTER_MAX_BYTES, ROLES, VIDEO_STATUS } from '../config/constants.js' +import { categoryRepo } from '../repositories/categoryRepo.js' +import { videoRepo } from '../repositories/videoRepo.js' +import { notFound, validationError } from '../utils/AppError.js' +import { sniffImage, sniffVideo } from '../utils/files.js' +import { logger } from '../utils/logger.js' +import { storageService } from './storage/index.js' + +const isAdmin = (user) => user?.role === ROLES.ADMIN + +async function assertCategoryExists(categoryId) { + if (categoryId && !(await categoryRepo.findById(categoryId))) throw validationError({ categoryId: 'That category does not exist.' }) +} + +/** Contributors only get published videos that sit in a category; anything else looks like it does not exist. */ +async function findVisible(id, requester) { + const video = await videoRepo.findById(id) + const visible = video && (isAdmin(requester) || (video.status === VIDEO_STATUS.PUBLISHED && video.category)) + if (!visible) throw notFound('That video is not available.') + return video +} + +async function describeOrNotFound(ref) { + try { + return await storageService.describe(ref) + } catch (error) { + logger.error({ err: error, key: ref.key }, 'Stored file could not be read') + throw notFound('That file is missing.') + } +} + +export const videoService = { + list: (requester) => videoRepo.listVisible({ isAdmin: isAdmin(requester) }), + get: findVisible, + + /** `files` is multer's { video: [file], poster?: [file] }. Files are checked by their real content. */ + async create(admin, input, files) { + const upload = files?.video?.[0] + const posterUpload = files?.poster?.[0] + if (!upload) throw validationError({ video: 'Choose a video to upload.' }) + + const videoKind = await sniffVideo(upload.path) + if (!videoKind) throw validationError({ video: 'That does not look like an MP4, WebM or MOV video.' }) + let posterKind = null + if (posterUpload) { + if (posterUpload.size > POSTER_MAX_BYTES) throw validationError({ poster: 'Poster images must be under 2 MB.' }) + posterKind = await sniffImage(posterUpload.path) + if (!posterKind) throw validationError({ poster: 'The poster must be a JPEG, PNG or WebP image.' }) + } + await assertCategoryExists(input.categoryId) + + const stored = [] + try { + const videoFile = await storageService.save({ tempPath: upload.path, originalName: upload.originalname, mimeType: videoKind.mimeType, ext: videoKind.ext }, { folder: 'videos' }) + stored.push(videoFile) + let posterFile = null + if (posterUpload) { + posterFile = await storageService.save({ tempPath: posterUpload.path, originalName: posterUpload.originalname, mimeType: posterKind.mimeType, ext: posterKind.ext }, { folder: 'posters' }) + stored.push(posterFile) + } + const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 + return await videoRepo.create({ + title: input.title, level: input.level, status: input.status, durationSec: input.durationSec, + category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, + }) + } catch (error) { + await Promise.all(stored.map((ref) => storageService.remove(ref))) // no orphaned files if the database write fails + throw error + } + }, + + async update(id, patch) { + const video = await videoRepo.findById(id) + if (!video) throw notFound('That video does not exist.') + + if ('categoryId' in patch) { + await assertCategoryExists(patch.categoryId) + const moved = String(video.category ?? '') !== String(patch.categoryId ?? '') + video.category = patch.categoryId + // Moving into a category puts the video at the end of that path unless an order was given. + if (moved && patch.categoryId && patch.order === undefined) video.order = (await videoRepo.maxOrder(patch.categoryId)) + 1 + } + for (const key of ['title', 'level', 'status', 'order']) { + if (patch[key] !== undefined) video[key] = patch[key] + } + return videoRepo.save(video) + }, + + async remove(id) { + const video = await videoRepo.findById(id) + if (!video) throw notFound('That video does not exist.') + await videoRepo.remove(video.id) + // Contributors' past submissions are kept on record; only the base video's own files go. + await Promise.all([storageService.remove(video.videoFile), storageService.remove(video.posterFile)]) + }, + + async getFile(id, requester) { + const video = await findVisible(id, requester) + return describeOrNotFound(video.videoFile) + }, + + async getPoster(id, requester) { + const video = await findVisible(id, requester) + if (!video.posterFile) throw notFound('That video has no poster.') + return describeOrNotFound(video.posterFile) + }, +} diff --git a/backend/src/utils/AppError.js b/backend/src/utils/AppError.js new file mode 100644 index 0000000..fb81d49 --- /dev/null +++ b/backend/src/utils/AppError.js @@ -0,0 +1,23 @@ +/** + * Every failure the API expects to happen is an AppError. Services throw them, + * middlewares/errorHandler turns them into `{ error: { code, message, fields } }`. + * `fields` maps input names to messages so a form can show them next to the right input. + */ +export class AppError extends Error { + constructor(status, code, message, fields) { + super(message) + this.name = 'AppError' + this.status = status + this.code = code + this.fields = fields + } +} + +export const badRequest = (message = 'The request could not be understood.') => new AppError(400, 'BAD_REQUEST', message) +export const validationError = (fields, message = 'Some fields need attention.') => new AppError(422, 'VALIDATION_ERROR', message, fields) +export const unauthorized = (message = 'Log in to continue.') => new AppError(401, 'UNAUTHORIZED', message) +export const forbidden = (message = 'You do not have access to this.') => new AppError(403, 'FORBIDDEN', message) +export const notFound = (message = 'We could not find that.') => new AppError(404, 'NOT_FOUND', message) +export const conflict = (message, fields) => new AppError(409, 'CONFLICT', message, fields) +export const payloadTooLarge = (message = 'That file is too large.') => new AppError(413, 'PAYLOAD_TOO_LARGE', message) +export const tooManyRequests = (message = 'Too many attempts. Wait a moment and try again.') => new AppError(429, 'TOO_MANY_REQUESTS', message) diff --git a/backend/src/utils/asyncHandler.js b/backend/src/utils/asyncHandler.js new file mode 100644 index 0000000..cc66599 --- /dev/null +++ b/backend/src/utils/asyncHandler.js @@ -0,0 +1,2 @@ +// Express 4 does not catch rejected promises from async handlers, so wrap them. +export const asyncHandler = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next) diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js new file mode 100644 index 0000000..ae5e6d1 --- /dev/null +++ b/backend/src/utils/files.js @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises' + +/** Best-effort delete; a missing file is fine. */ +export const removeQuietly = async (path) => { if (path) await fs.unlink(path).catch(() => {}) } + +async function readHead(path, length = 16) { + const handle = await fs.open(path, 'r') + try { + const buffer = Buffer.alloc(length) + const { bytesRead } = await handle.read(buffer, 0, length, 0) + return buffer.subarray(0, bytesRead) + } finally { await handle.close() } +} + +/** + * Identifies a video by its first bytes instead of trusting the browser-supplied + * Content-Type or file name. Returns { mimeType, ext } or null. + */ +export async function sniffVideo(path) { + const head = await readHead(path) + if (head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3) return { mimeType: 'video/webm', ext: '.webm' } + if (head.length >= 12 && head.toString('ascii', 4, 8) === 'ftyp') { + const brand = head.toString('ascii', 8, 12) + return brand === 'qt ' ? { mimeType: 'video/quicktime', ext: '.mov' } : { mimeType: 'video/mp4', ext: '.mp4' } + } + return null +} + +export async function sniffImage(path) { + const head = await readHead(path) + if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return { mimeType: 'image/jpeg', ext: '.jpg' } + if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47) return { mimeType: 'image/png', ext: '.png' } + if (head.length >= 12 && head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP') return { mimeType: 'image/webp', ext: '.webp' } + return null +} diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js new file mode 100644 index 0000000..fafc277 --- /dev/null +++ b/backend/src/utils/logger.js @@ -0,0 +1,7 @@ +import pino from 'pino' +import { env } from '../config/env.js' + +export const logger = pino({ + level: env.isTest ? 'silent' : env.LOG_LEVEL, + redact: ['req.headers.cookie', 'req.headers.authorization', 'res.headers["set-cookie"]'], +}) diff --git a/backend/src/utils/mongoose.js b/backend/src/utils/mongoose.js new file mode 100644 index 0000000..c326951 --- /dev/null +++ b/backend/src/utils/mongoose.js @@ -0,0 +1,11 @@ +/** Shared toJSON transform: `_id` becomes `id`, internals disappear. `extra` can reshape further. */ +export const jsonOptions = (extra) => ({ + versionKey: false, + transform(doc, ret) { + ret.id = String(ret._id) + delete ret._id + return extra ? extra(doc, ret) : ret + }, +}) + +export const isDuplicateKeyError = (error) => error?.code === 11000 diff --git a/backend/src/utils/response.js b/backend/src/utils/response.js new file mode 100644 index 0000000..7ca1b43 --- /dev/null +++ b/backend/src/utils/response.js @@ -0,0 +1,4 @@ +// One success shape for the whole API: { data }. +export const ok = (res, data, status = 200) => res.status(status).json({ data }) +export const created = (res, data) => ok(res, data, 201) +export const noContent = (res) => res.status(204).end() diff --git a/backend/src/utils/sendFile.js b/backend/src/utils/sendFile.js new file mode 100644 index 0000000..43f625e --- /dev/null +++ b/backend/src/utils/sendFile.js @@ -0,0 +1,42 @@ +/** + * Streams a stored file with HTTP Range support, which browsers need for seeking inside