diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..409f97c --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +ENVIRONMENT = DEV # DEV | PROD +TURSO_DATABASE_AUTH_TOKEN = adfasdfsdfsdfsdfsdf +TURSO_DATABASE_URL = https://127.0.0.1:8080/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 27eeeef..4bdf6cd 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,11 +2,12 @@ "root": true, "extends": ["plugin:@typescript-eslint/recommended"], "parser": "@typescript-eslint/parser", - "plugins": ["json"], + "plugins": [], "rules": { "no-const-assign": "error", - "camelcase": "error", - "no-extra-semi": "error" + "no-extra-semi": "error", + "no-unreachable": "error" + // "sort-imports": "error" }, "parserOptions": { "allowImportExportEverywhere": true, diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1c6708c..9b35e52 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,25 +14,24 @@ jobs: name: Prettier formatting runs-on: ubuntu-latest steps: - - name: Checkout repo + - name: Checkout repository uses: actions/checkout@v3 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20.9.0 - - name: Install pnpm - uses: pnpm/action-setup@v2 + - name: Install bun + uses: oven-sh/setup-bun@v1 with: - version: latest - run_install: false + bun-version: latest - name: Install dependencies - run: pnpm install + run: bun i - name: Prettier check - run: pnpm run prettier:check + run: bun run prettier:check lint: name: ESLint @@ -42,21 +41,25 @@ jobs: uses: actions/checkout@v3 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + + - name: Setup Node.js + uses: oven-sh/setup-bun@v1 with: - node-version: 18 + bun-version: latest - - name: Install pnpm - uses: pnpm/action-setup@v2 + - name: Install bun + uses: oven-sh/setup-bun@v1 with: - version: latest - run_install: false + bun-version: latest - name: Install dependencies - run: pnpm install + run: bun i - name: Lint - run: pnpm run lint + run: bun run lint typecheck: name: TypeScript types @@ -66,18 +69,17 @@ jobs: uses: actions/checkout@v3 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20.9.0 - - name: Install pnpm - uses: pnpm/action-setup@v2 + - name: Install bun + uses: oven-sh/setup-bun@v1 with: - version: latest - run_install: false + bun-version: latest - name: Install dependencies - run: pnpm install + run: bun i - name: Lint - run: pnpm run typecheck + run: bun run typecheck diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 766ba86..7102a30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,31 +14,17 @@ jobs: uses: actions/checkout@v3 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20.9.0 - - name: Install pnpm - uses: pnpm/action-setup@v2 + - name: Install bun + uses: oven-sh/setup-bun@v1 with: - version: latest - run_install: false - - # - name: Get pnpm store directory - # id: pnpm-cache - # run: | - # echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - # - uses: actions/cache@v3 - # name: Setup pnpm cache - # with: - # path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - # key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - # restore-keys: | - # ${{ runner.os }}-pnpm-store- + bun-version: latest - name: Install dependencies - run: pnpm install + run: bun i - name: Deploy to Cloudflare Workers using Wrangler uses: cloudflare/wrangler-action@2.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5246f55 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Run Tests +on: + push: + branches: + - "*" + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + # local sqld instance for "turso dev" + services: + sqld: + image: ghcr.io/libsql/sqld:latest + ports: + - 8080:8080 + + env: + ENVIRONMENT: DEV + TURSO_DEV_DATABASE_URL: http://127.0.0.1:8080 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + + - name: Install bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun i + + - name: Create .dev.vars file + run: | + echo ENVIRONMENT=$ENVIRONMENT >> .dev.vars + echo TURSO_DEV_DATABASE_URL=$TURSO_DEV_DATABASE_URL >> .dev.vars + + - name: Initialize Drizzle + run: bun run drizzle:dev:init diff --git a/.gitignore b/.gitignore index f70d5b1..8b9ded3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -# Wrangler +# webstorm +.idea +# Wrangler & env + +.env .wrangler .dev.vars @@ -11,7 +15,6 @@ npm-debug.log_ yarn-debug.log* yarn-error.log* lerna-debug.log* -.pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8bc4880 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +bun run typecheck && bun run lint && bun run prettier:check diff --git a/.prettierrc b/.prettierrc index d2b1642..40b6dab 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,7 @@ "trailingComma": "es5", "endOfLine": "lf", "tabWidth": 4, + "semi": false, + "useTabs": false, "plugins": [] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/README.md b/README.md index ee5368d..69d2fd2 100644 --- a/README.md +++ b/README.md @@ -2,75 +2,77 @@ ![Banner] -![Quality] ![API Response] ![CDN Response] +![Quality] -Source code for the API powering [**wanderer.moe**](https://wanderer.moe) — using **Cloudflare Workers** with **R2 Storage** for the CDN, and **D1** for the Database. +Source code for the API powering [**wanderer.moe**](https://wanderer.moe). +Tech Stack: + +- Turso - Database (Libsql, fork of SQLite) +- Drizzle - ORM +- Hono - Framework +- Scalar - OpenAPI Documentation +- Bun - Bun +- Resend - Emails +- Cloudflare R2 - Storage +- Cloudflare DO - Ratelimiting +- Lucia Auth - Authentication + --- ## Usage -#### Wrangler +### Bun -Configuration is in `wrangler.toml` - this includes the R2 Bucket and D1 Database. +Bun is relatively lightweight and easy to use. You can install it with `npm i bun -g` globally using `npm`. -- Run `wrangler dev` to preview locally. -- Run `wrangler deploy` to publish to Cloudflare Workers. +### Turso -#### Actions +We use Turso (libsql, very lightweight fork of SQLite) as our database. You will need to install the [Turso CLI](https://docs.turso.tech/reference/turso-cli#installation) then run `turso dev` to start a local database. You can persist data by passing `--db-file `. -- There is a GitHub Action that automatically deploys to Cloudflare Workers on every push to `main` — you can find it in `.github/workflows/deploy.yml`. +The Turso CLI can be run on Windows using WSL and the `--headless` flag. -- If you're using Github Actions, you will have to setup a secret with a Cloudflare API token. You can generate the API token [here][Cloudflare API Token] — use the `Edit Cloudflare Workers` template. +To install `sqld` for dev db `wget https://github.com/libsql/sqld/releases/download/v0.21.9/sqld-installer.sh` or whatever the newest version is, then `sh sqld-installer.sh`. -## API Reference +The API will connect to the local database if the environment is set to `DEV` in `.dev.vars`, else - it will connect to your production database. -### Games +To generate seed data, generate and migrate, you can run `bun run drizzle:init:dev`. -#### Get all games +### Wrangler -```http - GET api.wanderer.moe/games -``` +Configuration is in `wrangler.toml`. -#### Get game data +You will most likely require a workers paid plan for authentication and password hashing to work. -```http - GET api.wanderer.moe/game/${gameId} -``` +Required environment variables are viewable in `./src/worker-configuration.d.ts`. -| Parameter | Type | Description | -| :-------- | :------- | :---------------------------------- | -| `gameId` | `string` | **Required** — game to get data for | +- Run `wrangler dev` to preview locally. +- Run `wrangler deploy` to publish to Cloudflare Workers. + +### Actions + +- There is a GitHub Action that automatically deploys to Cloudflare Workers on every push to `main` — you can find it in `.github/workflows/deploy.yml`. -#### Get a game's asset data +- If you're using GitHub Actions, you will have to set up a secret with a Cloudflare API token. You can generate the API token [here][Cloudflare API Token] — use the `Edit Cloudflare Workers` template. -```http - GET api.wanderer.moe/game/${gameId}/${asset} -``` +### Database -| Parameter | Type | Description | -| :-------- | :------- | :----------------------------------- | -| `gameId` | `string` | **Required** — game to get data for | -| `asset` | `string` | **Required** — asset to get data for | +- When migrating, you will need `tsx`. +- It's not reccomended to use `drizzle:push` in production. However, there is `drizzle:generate` & `drizzle:migrate` available as scripts. ## Authors -- [@dromzeh][Dromzeh] +- [@dromzeh][dromzeh] ## License [api.wanderer.moe][api.wanderer.moe] is licensed under the [GNU Affero General Public License v3.0][License] - **You must state all significant changes made to the original software, make the source code available to the public with credit to the original author, original source, and use the same license.** [Banner]: https://files.catbox.moe/qa3eus.svg -[API Status]: https://status.wanderer.moe/history/api -[CDN Status]: https://status.wanderer.moe/history/cdn -[API Response]: https://img.shields.io/endpoint?label=API%20Response&style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fwanderer-moe%2Fstatus%2FHEAD%2Fapi%2Fapi%2Fresponse-time.json -[CDN Response]: https://img.shields.io/endpoint?label=CDN%20Response&style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fwanderer-moe%2Fstatus%2FHEAD%2Fapi%2Fcdn%2Fresponse-time.json [Quality]: https://img.shields.io/codefactor/grade/github/wanderer-moe/api?label=quality&style=for-the-badge [Cloudflare API Token]: https://dash.cloudflare.com/profile/api-tokens -[Dromzeh]: https://github.com/dromzeh +[dromzeh]: https://github.com/dromzeh [api.wanderer.moe]: https://api.wanderer.moe [License]: LICENSE diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..89ee23d Binary files /dev/null and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..7fc2a98 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,13 @@ +import type { Config } from "drizzle-kit" + +export default { + out: "./src/v2/db/migrations", + schema: "./src/v2/db/schema.ts", + driver: "turso", + breakpoints: true, + strict: true, + verbose: true, + dbCredentials: { + url: "http://127.0.0.1:8080", + }, +} satisfies Config diff --git a/package.json b/package.json index 94de552..3b98ab3 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,53 @@ { - "name": "wanderer-moe-api", - "version": "1.0.1b", + "name": "api", + "version": "2.0.0b", "scripts": { - "prettier": "prettier --write .", - "dev": "wrangler dev --remote", + "dev": "wrangler dev", + "wrangler:dev:local": "wrangler dev", + "wrangler:dev:remote": "wrangler dev --remote", + "wrangler:publish": "wrangler publish --minify", "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "typecheck": "tsc --noEmit", + "prettier:format": "prettier --write .", "prettier:check": "prettier --check .", - "typecheck": "tsc --noEmit" + "drizzle:generate": "drizzle-kit generate:sqlite", + "drizzle:seed": "tsx src/scripts/seed/seed.ts", + "drizzle:migrate": "tsx src/scripts/migrate/migrate.mts", + "drizzle:push": "drizzle-kit push:sqlite", + "drizzle:dev:init": "drizzle-kit generate:sqlite && tsx src/scripts/migrate/migrate.mts && tsx src/scripts/seed/seed.ts", + "drizzle:studio": "drizzle-kit studio --port 7331 --host 127.0.0.1 --verbose" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20230518.0", - "eslint": "^8.42.0", - "eslint-config-google": "^0.14.0", - "eslint-plugin-json": "^3.1.0", - "typescript": "^5.1.3", - "wrangler": "3.1.1" + "@asteasolutions/zod-to-openapi": "^6.4.0", + "@cloudflare/workers-types": "^4.20240314.0", + "@types/node": "^20.11.28", + "dotenv": "^16.4.5", + "drizzle-kit": "^0.20.14", + "eslint": "^8.57.0", + "husky": "^9.0.11", + "tsx": "^4.7.1", + "typescript": "^5.4.2", + "wrangler": "3.34.2" }, "private": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "^5.59.11", - "itty-router": "^4.0.9", - "prettier": "^2.8.8", - "render2": "^1.2.1" + "@axiomhq/js": "1.0.0-rc.2", + "@hono/swagger-ui": "^0.2.1", + "@hono/zod-openapi": "^0.9.8", + "@libsql/client": "0.5.6", + "@lucia-auth/adapter-sqlite": "3.0.1", + "@scalar/hono-api-reference": "^0.4.5", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "better-sqlite3": "^9.4.3", + "dayjs": "^1.11.10", + "drizzle-orm": "^0.30.2", + "drizzle-zod": "^0.5.1", + "hono": "^4.1.0", + "lucia": "3.1.1", + "oslo": "^1.1.3", + "prettier": "^3.2.5", + "zod": "^3.22.4", + "zod-error": "^1.5.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 7d5794a..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1781 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - '@typescript-eslint/eslint-plugin': - specifier: ^5.59.11 - version: 5.59.11(@typescript-eslint/parser@5.59.11)(eslint@8.42.0)(typescript@5.1.3) - itty-router: - specifier: ^4.0.9 - version: 4.0.9 - prettier: - specifier: ^2.8.8 - version: 2.8.8 - render2: - specifier: ^1.2.1 - version: 1.2.1 - -devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20230518.0 - version: 4.20230518.0 - eslint: - specifier: ^8.42.0 - version: 8.42.0 - eslint-config-google: - specifier: ^0.14.0 - version: 0.14.0(eslint@8.42.0) - eslint-plugin-json: - specifier: ^3.1.0 - version: 3.1.0 - typescript: - specifier: ^5.1.3 - version: 5.1.3 - wrangler: - specifier: 3.1.1 - version: 3.1.1 - -packages: - - /@cloudflare/kv-asset-handler@0.2.0: - resolution: {integrity: sha512-MVbXLbTcAotOPUj0pAMhVtJ+3/kFkwJqc5qNOleOZTv6QkZZABDMS21dSrSlVswEHwrpWC03e4fWytjqKvuE2A==} - dependencies: - mime: 3.0.0 - dev: true - - /@cloudflare/workerd-darwin-64@1.20230518.0: - resolution: {integrity: sha512-reApIf2/do6GjLlajU6LbRYh8gm/XcaRtzGbF8jo5IzyDSsdStmfNuvq7qssZXG92219Yp1kuTgR9+D1GGZGbg==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@cloudflare/workerd-darwin-arm64@1.20230518.0: - resolution: {integrity: sha512-1l+xdbmPddqb2YIHd1YJ3YG/Fl1nhayzcxfL30xfNS89zJn9Xn3JomM0XMD4mk0d5GruBP3q8BQZ1Uo4rRLF3A==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@cloudflare/workerd-linux-64@1.20230518.0: - resolution: {integrity: sha512-/pfR+YBpMOPr2cAlwjtInil0hRZjD8KX9LqK9JkfkEiaBH8CYhnJQcOdNHZI+3OjcY09JnQtEVC5xC4nbW7Bvw==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@cloudflare/workerd-linux-arm64@1.20230518.0: - resolution: {integrity: sha512-q3HQvn3J4uEkE0cfDAGG8zqzSZrD47cavB/Tzv4mNutqwg6B4wL3ifjtGeB55tnP2K2KL0GVmX4tObcvpUF4BA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@cloudflare/workerd-windows-64@1.20230518.0: - resolution: {integrity: sha512-vNEHKS5gKKduNOBYtQjcBopAmFT1iScuPWMZa2nJboSjOB9I/5oiVsUpSyk5Y2ARyrohXNz0y8D7p87YzTASWw==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@cloudflare/workers-types@4.20230518.0: - resolution: {integrity: sha512-A0w1V+5SUawGaaPRlhFhSC/SCDT9oQG8TMoWOKFLA4qbqagELqEAFD4KySBIkeVOvCBLT1DZSYBMCxbXddl0kw==} - dev: true - - /@esbuild-plugins/node-globals-polyfill@0.1.1(esbuild@0.16.3): - resolution: {integrity: sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==} - peerDependencies: - esbuild: '*' - dependencies: - esbuild: 0.16.3 - dev: true - - /@esbuild-plugins/node-modules-polyfill@0.1.4(esbuild@0.16.3): - resolution: {integrity: sha512-uZbcXi0zbmKC/050p3gJnne5Qdzw8vkXIv+c2BW0Lsc1ji1SkrxbKPUy5Efr0blbTu1SL8w4eyfpnSdPg3G0Qg==} - peerDependencies: - esbuild: '*' - dependencies: - esbuild: 0.16.3 - escape-string-regexp: 4.0.0 - rollup-plugin-node-polyfills: 0.2.1 - dev: true - - /@esbuild/android-arm64@0.16.3: - resolution: {integrity: sha512-RolFVeinkeraDvN/OoRf1F/lP0KUfGNb5jxy/vkIMeRRChkrX/HTYN6TYZosRJs3a1+8wqpxAo5PI5hFmxyPRg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm@0.16.3: - resolution: {integrity: sha512-mueuEoh+s1eRbSJqq9KNBQwI4QhQV6sRXIfTyLXSHGMpyew61rOK4qY21uKbXl1iBoMb0AdL1deWFCQVlN2qHA==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-x64@0.16.3: - resolution: {integrity: sha512-SFpTUcIT1bIJuCCBMCQWq1bL2gPTjWoLZdjmIhjdcQHaUfV41OQfho6Ici5uvvkMmZRXIUGpM3GxysP/EU7ifQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-arm64@0.16.3: - resolution: {integrity: sha512-DO8WykMyB+N9mIDfI/Hug70Dk1KipavlGAecxS3jDUwAbTpDXj0Lcwzw9svkhxfpCagDmpaTMgxWK8/C/XcXvw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-x64@0.16.3: - resolution: {integrity: sha512-uEqZQ2omc6BvWqdCiyZ5+XmxuHEi1SPzpVxXCSSV2+Sh7sbXbpeNhHIeFrIpRjAs0lI1FmA1iIOxFozKBhKgRQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-arm64@0.16.3: - resolution: {integrity: sha512-nJansp3sSXakNkOD5i5mIz2Is/HjzIhFs49b1tjrPrpCmwgBmH9SSzhC/Z1UqlkivqMYkhfPwMw1dGFUuwmXhw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-x64@0.16.3: - resolution: {integrity: sha512-TfoDzLw+QHfc4a8aKtGSQ96Wa+6eimljjkq9HKR0rHlU83vw8aldMOUSJTUDxbcUdcgnJzPaX8/vGWm7vyV7ug==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64@0.16.3: - resolution: {integrity: sha512-7I3RlsnxEFCHVZNBLb2w7unamgZ5sVwO0/ikE2GaYvYuUQs9Qte/w7TqWcXHtCwxvZx/2+F97ndiUQAWs47ZfQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm@0.16.3: - resolution: {integrity: sha512-VwswmSYwVAAq6LysV59Fyqk3UIjbhuc6wb3vEcJ7HEJUtFuLK9uXWuFoH1lulEbE4+5GjtHi3MHX+w1gNHdOWQ==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ia32@0.16.3: - resolution: {integrity: sha512-X8FDDxM9cqda2rJE+iblQhIMYY49LfvW4kaEjoFbTTQ4Go8G96Smj2w3BRTwA8IHGoi9dPOPGAX63dhuv19UqA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-loong64@0.16.3: - resolution: {integrity: sha512-hIbeejCOyO0X9ujfIIOKjBjNAs9XD/YdJ9JXAy1lHA+8UXuOqbFe4ErMCqMr8dhlMGBuvcQYGF7+kO7waj2KHw==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-mips64el@0.16.3: - resolution: {integrity: sha512-znFRzICT/V8VZQMt6rjb21MtAVJv/3dmKRMlohlShrbVXdBuOdDrGb+C2cZGQAR8RFyRe7HS6klmHq103WpmVw==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ppc64@0.16.3: - resolution: {integrity: sha512-EV7LuEybxhXrVTDpbqWF2yehYRNz5e5p+u3oQUS2+ZFpknyi1NXxr8URk4ykR8Efm7iu04//4sBg249yNOwy5Q==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-riscv64@0.16.3: - resolution: {integrity: sha512-uDxqFOcLzFIJ+r/pkTTSE9lsCEaV/Y6rMlQjUI9BkzASEChYL/aSQjZjchtEmdnVxDKETnUAmsaZ4pqK1eE5BQ==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-s390x@0.16.3: - resolution: {integrity: sha512-NbeREhzSxYwFhnCAQOQZmajsPYtX71Ufej3IQ8W2Gxskfz9DK58ENEju4SbpIj48VenktRASC52N5Fhyf/aliQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-x64@0.16.3: - resolution: {integrity: sha512-SDiG0nCixYO9JgpehoKgScwic7vXXndfasjnD5DLbp1xltANzqZ425l7LSdHynt19UWOcDjG9wJJzSElsPvk0w==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-x64@0.16.3: - resolution: {integrity: sha512-AzbsJqiHEq1I/tUvOfAzCY15h4/7Ivp3ff/o1GpP16n48JMNAtbW0qui2WCgoIZArEHD0SUQ95gvR0oSO7ZbdA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-x64@0.16.3: - resolution: {integrity: sha512-gSABi8qHl8k3Cbi/4toAzHiykuBuWLZs43JomTcXkjMZVkp0gj3gg9mO+9HJW/8GB5H89RX/V0QP4JGL7YEEVg==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/sunos-x64@0.16.3: - resolution: {integrity: sha512-SF9Kch5Ete4reovvRO6yNjMxrvlfT0F0Flm+NPoUw5Z4Q3r1d23LFTgaLwm3Cp0iGbrU/MoUI+ZqwCv5XJijCw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-arm64@0.16.3: - resolution: {integrity: sha512-u5aBonZIyGopAZyOnoPAA6fGsDeHByZ9CnEzyML9NqntK6D/xl5jteZUKm/p6nD09+v3pTM6TuUIqSPcChk5gg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-ia32@0.16.3: - resolution: {integrity: sha512-GlgVq1WpvOEhNioh74TKelwla9KDuAaLZrdxuuUgsP2vayxeLgVc+rbpIv0IYF4+tlIzq2vRhofV+KGLD+37EQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-x64@0.16.3: - resolution: {integrity: sha512-5/JuTd8OWW8UzEtyf19fbrtMJENza+C9JoPIkvItgTBQ1FO2ZLvjbPO6Xs54vk0s5JB5QsfieUEshRQfu7ZHow==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@eslint-community/eslint-utils@4.4.0(eslint@8.42.0): - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.42.0 - eslint-visitor-keys: 3.4.1 - - /@eslint-community/regexpp@4.5.1: - resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - /@eslint/eslintrc@2.0.3: - resolution: {integrity: sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.3.4 - espree: 9.5.2 - globals: 13.20.0 - ignore: 5.2.4 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - /@eslint/js@8.42.0: - resolution: {integrity: sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - /@humanwhocodes/config-array@0.11.10: - resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} - engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - /@humanwhocodes/module-importer@1.0.1: - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - /@humanwhocodes/object-schema@1.2.1: - resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} - - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 - - /@types/json-schema@7.0.12: - resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} - dev: false - - /@types/semver@7.5.0: - resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} - dev: false - - /@typescript-eslint/eslint-plugin@5.59.11(@typescript-eslint/parser@5.59.11)(eslint@8.42.0)(typescript@5.1.3): - resolution: {integrity: sha512-XxuOfTkCUiOSyBWIvHlUraLw/JT/6Io1365RO6ZuI88STKMavJZPNMU0lFcUTeQXEhHiv64CbxYxBNoDVSmghg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 5.59.11(eslint@8.42.0)(typescript@5.1.3) - '@typescript-eslint/scope-manager': 5.59.11 - '@typescript-eslint/type-utils': 5.59.11(eslint@8.42.0)(typescript@5.1.3) - '@typescript-eslint/utils': 5.59.11(eslint@8.42.0)(typescript@5.1.3) - debug: 4.3.4 - eslint: 8.42.0 - grapheme-splitter: 1.0.4 - ignore: 5.2.4 - natural-compare-lite: 1.4.0 - semver: 7.5.1 - tsutils: 3.21.0(typescript@5.1.3) - typescript: 5.1.3 - transitivePeerDependencies: - - supports-color - dev: false - - /@typescript-eslint/parser@5.59.11(eslint@8.42.0)(typescript@5.1.3): - resolution: {integrity: sha512-s9ZF3M+Nym6CAZEkJJeO2TFHHDsKAM3ecNkLuH4i4s8/RCPnF5JRip2GyviYkeEAcwGMJxkqG9h2dAsnA1nZpA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/scope-manager': 5.59.11 - '@typescript-eslint/types': 5.59.11 - '@typescript-eslint/typescript-estree': 5.59.11(typescript@5.1.3) - debug: 4.3.4 - eslint: 8.42.0 - typescript: 5.1.3 - transitivePeerDependencies: - - supports-color - dev: false - - /@typescript-eslint/scope-manager@5.59.11: - resolution: {integrity: sha512-dHFOsxoLFtrIcSj5h0QoBT/89hxQONwmn3FOQ0GOQcLOOXm+MIrS8zEAhs4tWl5MraxCY3ZJpaXQQdFMc2Tu+Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - '@typescript-eslint/types': 5.59.11 - '@typescript-eslint/visitor-keys': 5.59.11 - dev: false - - /@typescript-eslint/type-utils@5.59.11(eslint@8.42.0)(typescript@5.1.3): - resolution: {integrity: sha512-LZqVY8hMiVRF2a7/swmkStMYSoXMFlzL6sXV6U/2gL5cwnLWQgLEG8tjWPpaE4rMIdZ6VKWwcffPlo1jPfk43g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: '*' - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/typescript-estree': 5.59.11(typescript@5.1.3) - '@typescript-eslint/utils': 5.59.11(eslint@8.42.0)(typescript@5.1.3) - debug: 4.3.4 - eslint: 8.42.0 - tsutils: 3.21.0(typescript@5.1.3) - typescript: 5.1.3 - transitivePeerDependencies: - - supports-color - dev: false - - /@typescript-eslint/types@5.59.11: - resolution: {integrity: sha512-epoN6R6tkvBYSc+cllrz+c2sOFWkbisJZWkOE+y3xHtvYaOE6Wk6B8e114McRJwFRjGvYdJwLXQH5c9osME/AA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: false - - /@typescript-eslint/typescript-estree@5.59.11(typescript@5.1.3): - resolution: {integrity: sha512-YupOpot5hJO0maupJXixi6l5ETdrITxeo5eBOeuV7RSKgYdU3G5cxO49/9WRnJq9EMrB7AuTSLH/bqOsXi7wPA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/types': 5.59.11 - '@typescript-eslint/visitor-keys': 5.59.11 - debug: 4.3.4 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.5.1 - tsutils: 3.21.0(typescript@5.1.3) - typescript: 5.1.3 - transitivePeerDependencies: - - supports-color - dev: false - - /@typescript-eslint/utils@5.59.11(eslint@8.42.0)(typescript@5.1.3): - resolution: {integrity: sha512-didu2rHSOMUdJThLk4aZ1Or8IcO3HzCw/ZvEjTTIfjIrcdd5cvSIwwDy2AOlE7htSNp7QIZ10fLMyRCveesMLg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.42.0) - '@types/json-schema': 7.0.12 - '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 5.59.11 - '@typescript-eslint/types': 5.59.11 - '@typescript-eslint/typescript-estree': 5.59.11(typescript@5.1.3) - eslint: 8.42.0 - eslint-scope: 5.1.1 - semver: 7.5.1 - transitivePeerDependencies: - - supports-color - - typescript - dev: false - - /@typescript-eslint/visitor-keys@5.59.11: - resolution: {integrity: sha512-KGYniTGG3AMTuKF9QBD7EIrvufkB6O6uX3knP73xbKLMpH+QRPcgnCxjWXSHjMRuOxFLovljqQgQpR0c7GvjoA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - '@typescript-eslint/types': 5.59.11 - eslint-visitor-keys: 3.4.1 - dev: false - - /acorn-jsx@5.3.2(acorn@8.8.2): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.8.2 - - /acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - dev: true - - /acorn@8.8.2: - resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} - engines: {node: '>=0.4.0'} - hasBin: true - - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: false - - /as-table@1.0.55: - resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} - dependencies: - printable-characters: 1.0.42 - dev: true - - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - /base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true - - /better-sqlite3@8.4.0: - resolution: {integrity: sha512-NmsNW1CQvqMszu/CFAJ3pLct6NEFlNfuGM6vw72KHkjOD1UDnL96XNN1BMQc1hiHo8vE2GbOWQYIpZ+YM5wrZw==} - requiresBuild: true - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.1 - dev: true - - /binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - dev: true - - /bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - dependencies: - file-uri-to-path: 1.0.0 - dev: true - - /bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - dev: true - - /blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - dev: true - - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.0.1 - - /buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - dev: true - - /buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - dev: true - - /busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - dependencies: - streamsearch: 1.1.0 - dev: true - - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - /capnp-ts@0.7.0: - resolution: {integrity: sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==} - dependencies: - debug: 4.3.4 - tslib: 2.5.3 - transitivePeerDependencies: - - supports-color - dev: true - - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - /chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.3 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.2 - dev: true - - /chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - dev: true - - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - /cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} - engines: {node: '>= 0.6'} - dev: true - - /cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - /data-uri-to-buffer@2.0.2: - resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} - dev: true - - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - - /decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - dependencies: - mimic-response: 3.1.0 - dev: true - - /deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - dev: true - - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - /detect-libc@2.0.1: - resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==} - engines: {node: '>=8'} - dev: true - - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dependencies: - path-type: 4.0.0 - dev: false - - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dependencies: - esutils: 2.0.3 - - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: true - - /esbuild@0.16.3: - resolution: {integrity: sha512-71f7EjPWTiSguen8X/kxEpkAS7BFHwtQKisCDDV3Y4GLGWBaoSCyD5uXkaUew6JDzA9FEN1W23mdnSwW9kqCeg==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/android-arm': 0.16.3 - '@esbuild/android-arm64': 0.16.3 - '@esbuild/android-x64': 0.16.3 - '@esbuild/darwin-arm64': 0.16.3 - '@esbuild/darwin-x64': 0.16.3 - '@esbuild/freebsd-arm64': 0.16.3 - '@esbuild/freebsd-x64': 0.16.3 - '@esbuild/linux-arm': 0.16.3 - '@esbuild/linux-arm64': 0.16.3 - '@esbuild/linux-ia32': 0.16.3 - '@esbuild/linux-loong64': 0.16.3 - '@esbuild/linux-mips64el': 0.16.3 - '@esbuild/linux-ppc64': 0.16.3 - '@esbuild/linux-riscv64': 0.16.3 - '@esbuild/linux-s390x': 0.16.3 - '@esbuild/linux-x64': 0.16.3 - '@esbuild/netbsd-x64': 0.16.3 - '@esbuild/openbsd-x64': 0.16.3 - '@esbuild/sunos-x64': 0.16.3 - '@esbuild/win32-arm64': 0.16.3 - '@esbuild/win32-ia32': 0.16.3 - '@esbuild/win32-x64': 0.16.3 - dev: true - - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - /eslint-config-google@0.14.0(eslint@8.42.0): - resolution: {integrity: sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==} - engines: {node: '>=0.10.0'} - peerDependencies: - eslint: '>=5.16.0' - dependencies: - eslint: 8.42.0 - dev: true - - /eslint-plugin-json@3.1.0: - resolution: {integrity: sha512-MrlG2ynFEHe7wDGwbUuFPsaT2b1uhuEFhJ+W1f1u+1C2EkXmTYJp4B1aAdQQ8M+CC3t//N/oRKiIVw14L2HR1g==} - engines: {node: '>=12.0'} - dependencies: - lodash: 4.17.21 - vscode-json-languageservice: 4.2.1 - dev: true - - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: false - - /eslint-scope@7.2.0: - resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - /eslint-visitor-keys@3.4.1: - resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - /eslint@8.42.0: - resolution: {integrity: sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - hasBin: true - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.42.0) - '@eslint-community/regexpp': 4.5.1 - '@eslint/eslintrc': 2.0.3 - '@eslint/js': 8.42.0 - '@humanwhocodes/config-array': 0.11.10 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.4 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.0 - eslint-visitor-keys: 3.4.1 - espree: 9.5.2 - esquery: 1.5.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.20.0 - graphemer: 1.4.0 - ignore: 5.2.4 - import-fresh: 3.3.0 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.1 - strip-ansi: 6.0.1 - strip-json-comments: 3.1.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - - /espree@9.5.2: - resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - acorn: 8.8.2 - acorn-jsx: 5.3.2(acorn@8.8.2) - eslint-visitor-keys: 3.4.1 - - /esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} - engines: {node: '>=0.10'} - dependencies: - estraverse: 5.3.0 - - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - dependencies: - estraverse: 5.3.0 - - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: false - - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - /estree-walker@0.6.1: - resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} - dev: true - - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - /exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} - engines: {node: '>=6'} - dev: true - - /expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - dev: true - - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - /fast-glob@3.2.12: - resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: false - - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - /fastq@1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} - dependencies: - reusify: 1.0.4 - - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flat-cache: 3.0.4 - - /file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - dev: true - - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - /flat-cache@3.0.4: - resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flatted: 3.2.7 - rimraf: 3.0.2 - - /flatted@3.2.7: - resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} - - /fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - dev: true - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /get-source@2.0.12: - resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} - dependencies: - data-uri-to-buffer: 2.0.2 - source-map: 0.6.1 - dev: true - - /github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - dev: true - - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - - /glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - - /glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - dev: true - - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - /globals@13.20.0: - resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - - /globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.2.12 - ignore: 5.2.4 - merge2: 1.4.1 - slash: 3.0.0 - dev: false - - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: false - - /graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - /http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - dev: true - - /ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true - - /ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} - engines: {node: '>= 4'} - - /import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - /ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - dev: true - - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - dependencies: - binary-extensions: 2.2.0 - dev: true - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - /itty-router@4.0.9: - resolution: {integrity: sha512-al8PIAJEWuWZcg4iwLcLiF7R9njsIQxrT27ik2Vfp1Mi5CBEVr1BDKbA1xpOyqkRbj9cCBQiTRpLIKnNO2YKlQ==} - dev: false - - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - dependencies: - argparse: 2.0.1 - - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - /jsonc-parser@3.2.0: - resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} - dev: true - - /kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - dev: true - - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - /lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true - - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - dependencies: - yallist: 4.0.0 - - /magic-string@0.25.9: - resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} - dependencies: - sourcemap-codec: 1.4.8 - dev: true - - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: false - - /micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.2 - picomatch: 2.3.1 - dev: false - - /mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - dev: true - - /mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - dev: true - - /miniflare@3.0.1: - resolution: {integrity: sha512-aLOB8d26lOTn493GOv1LmpGHVLSxmeT4MixPG/k3Ze10j0wDKnMj8wsFgbZ6Q4cr1N4faf8O3IbNRJuQ+rLoJA==} - engines: {node: '>=16.13'} - dependencies: - acorn: 8.8.2 - acorn-walk: 8.2.0 - better-sqlite3: 8.4.0 - capnp-ts: 0.7.0 - exit-hook: 2.2.1 - glob-to-regexp: 0.4.1 - http-cache-semantics: 4.1.1 - kleur: 4.1.5 - source-map-support: 0.5.21 - stoppable: 1.1.0 - undici: 5.22.1 - workerd: 1.20230518.0 - ws: 8.13.0 - youch: 3.2.3 - zod: 3.21.4 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: true - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true - - /mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - dev: true - - /ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - - /mustache@4.2.0: - resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} - hasBin: true - dev: true - - /nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true - - /napi-build-utils@1.0.2: - resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} - dev: true - - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: false - - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - /node-abi@3.44.0: - resolution: {integrity: sha512-MYjZTiAETGG28/7fBH1RjuY7vzDwYC5q5U4whCgM4jNEQcC0gAvN339LxXukmL2T2tGpzYTfp+LZ5RN7E5DwEg==} - engines: {node: '>=10'} - dependencies: - semver: 7.5.1 - dev: true - - /node-forge@1.3.1: - resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} - engines: {node: '>= 6.13.0'} - dev: true - - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: true - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - - /optionator@0.9.1: - resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} - engines: {node: '>= 0.8.0'} - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.3 - - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - /path-to-regexp@6.2.1: - resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} - dev: true - - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - dev: false - - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - /prebuild-install@7.1.1: - resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} - engines: {node: '>=10'} - hasBin: true - dependencies: - detect-libc: 2.0.1 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 1.0.2 - node-abi: 3.44.0 - pump: 3.0.0 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.1 - tunnel-agent: 0.6.0 - dev: true - - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - /prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - dev: false - - /printable-characters@1.0.42: - resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} - dev: true - - /pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} - dependencies: - end-of-stream: 1.4.4 - once: 1.4.0 - dev: true - - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} - engines: {node: '>=6'} - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - /range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - dev: false - - /rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - dev: true - - /readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - dev: true - - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: true - - /render2@1.2.1: - resolution: {integrity: sha512-HfLOYtG6p6jx6GG6uub7YGJ4iv+GlOwFmDtGdtSe2NQJ6peMZ0u76k7GAZ0z7GSf4e9UfeCcQxme4Mayh7DLqw==} - dependencies: - range-parser: 1.2.1 - dev: false - - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - /reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true - dependencies: - glob: 7.2.3 - - /rollup-plugin-inject@3.0.2: - resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} - deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. - dependencies: - estree-walker: 0.6.1 - magic-string: 0.25.9 - rollup-pluginutils: 2.8.2 - dev: true - - /rollup-plugin-node-polyfills@0.2.1: - resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} - dependencies: - rollup-plugin-inject: 3.0.2 - dev: true - - /rollup-pluginutils@2.8.2: - resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} - dependencies: - estree-walker: 0.6.1 - dev: true - - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - - /safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: true - - /selfsigned@2.1.1: - resolution: {integrity: sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==} - engines: {node: '>=10'} - dependencies: - node-forge: 1.3.1 - dev: true - - /semver@7.5.1: - resolution: {integrity: sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==} - engines: {node: '>=10'} - hasBin: true - dependencies: - lru-cache: 6.0.0 - - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - /simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - dev: true - - /simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - dev: true - - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: false - - /source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - dev: true - - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - dev: true - - /source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} - dev: true - - /sourcemap-codec@1.4.8: - resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} - deprecated: Please use @jridgewell/sourcemap-codec instead - dev: true - - /stacktracey@2.1.8: - resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} - dependencies: - as-table: 1.0.55 - get-source: 2.0.12 - dev: true - - /stoppable@1.1.0: - resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} - engines: {node: '>=4', npm: '>=6'} - dev: true - - /streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - dev: true - - /string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - dependencies: - safe-buffer: 5.2.1 - dev: true - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - - /strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - dev: true - - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - - /tar-fs@2.1.1: - resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.0 - tar-stream: 2.2.0 - dev: true - - /tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.4 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - dev: true - - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - - /tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - dev: false - - /tslib@2.5.3: - resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} - dev: true - - /tsutils@3.21.0(typescript@5.1.3): - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - dependencies: - tslib: 1.14.1 - typescript: 5.1.3 - dev: false - - /tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - dependencies: - safe-buffer: 5.2.1 - dev: true - - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - /typescript@5.1.3: - resolution: {integrity: sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==} - engines: {node: '>=14.17'} - hasBin: true - - /undici@5.22.1: - resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} - engines: {node: '>=14.0'} - dependencies: - busboy: 1.6.0 - dev: true - - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - dependencies: - punycode: 2.3.0 - - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true - - /vscode-json-languageservice@4.2.1: - resolution: {integrity: sha512-xGmv9QIWs2H8obGbWg+sIPI/3/pFgj/5OWBhNzs00BkYQ9UaB2F6JJaGB/2/YOZJ3BvLXQTC4Q7muqU25QgAhA==} - dependencies: - jsonc-parser: 3.2.0 - vscode-languageserver-textdocument: 1.0.8 - vscode-languageserver-types: 3.17.3 - vscode-nls: 5.2.0 - vscode-uri: 3.0.7 - dev: true - - /vscode-languageserver-textdocument@1.0.8: - resolution: {integrity: sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q==} - dev: true - - /vscode-languageserver-types@3.17.3: - resolution: {integrity: sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==} - dev: true - - /vscode-nls@5.2.0: - resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} - dev: true - - /vscode-uri@3.0.7: - resolution: {integrity: sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==} - dev: true - - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - dependencies: - isexe: 2.0.0 - - /word-wrap@1.2.3: - resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} - engines: {node: '>=0.10.0'} - - /workerd@1.20230518.0: - resolution: {integrity: sha512-VNmK0zoNZXrwEEx77O/oQDVUzzyDjf5kKKK8bty+FmKCd5EQJCpqi8NlRKWLGMyyYrKm86MFz0kAsreTEs7HHA==} - engines: {node: '>=16'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20230518.0 - '@cloudflare/workerd-darwin-arm64': 1.20230518.0 - '@cloudflare/workerd-linux-64': 1.20230518.0 - '@cloudflare/workerd-linux-arm64': 1.20230518.0 - '@cloudflare/workerd-windows-64': 1.20230518.0 - dev: true - - /wrangler@3.1.1: - resolution: {integrity: sha512-iG6QGOt+qgSm7UroJ8IJ+JdXEcDcW7yp9ilP0V7alCGhKm8shqa/M1iyMOpukZSCSZo8Vmn5nH2C9OY1PR3dQQ==} - engines: {node: '>=16.13.0'} - hasBin: true - dependencies: - '@cloudflare/kv-asset-handler': 0.2.0 - '@esbuild-plugins/node-globals-polyfill': 0.1.1(esbuild@0.16.3) - '@esbuild-plugins/node-modules-polyfill': 0.1.4(esbuild@0.16.3) - blake3-wasm: 2.1.5 - chokidar: 3.5.3 - esbuild: 0.16.3 - miniflare: 3.0.1 - nanoid: 3.3.6 - path-to-regexp: 6.2.1 - selfsigned: 2.1.1 - source-map: 0.7.4 - xxhash-wasm: 1.0.2 - optionalDependencies: - fsevents: 2.3.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: true - - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - /ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - - /xxhash-wasm@1.0.2: - resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} - dev: true - - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - /youch@3.2.3: - resolution: {integrity: sha512-ZBcWz/uzZaQVdCvfV4uk616Bbpf2ee+F/AvuKDR5EwX/Y4v06xWdtMluqTD7+KlZdM93lLm9gMZYo0sKBS0pgw==} - dependencies: - cookie: 0.5.0 - mustache: 4.2.0 - stacktracey: 2.1.8 - dev: true - - /zod@3.21.4: - resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} - dev: true diff --git a/src/handler.ts b/src/handler.ts deleted file mode 100644 index f6a8f68..0000000 --- a/src/handler.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Router } from "itty-router"; -import { errorHandler } from "@/middleware/errorHandler"; -import { responseHeaders } from "@/lib/responseHeaders"; -import { getContributors } from "@/routes/discord/contributors"; -import { index } from "@/routes/index"; -import { getGameId } from "@/routes/games/getGameId"; -import { getAsset } from "@/routes/games/getAsset"; -import { getGames } from "@/routes/games/getGames"; -import { getGeneratorGameId } from "@/routes/oc-generators/getGameId"; -import { getGenerators } from "@/routes/oc-generators/getGenerators"; - -const router = Router(); - -router - .get("/", errorHandler(index)) - .get("/games", errorHandler(getGames)) - .get("/game/:gameId", errorHandler(getGameId)) - .get("/game/:gameId/:asset", errorHandler(getAsset)) - .get("/oc-generators", errorHandler(getGenerators)) - .get("/oc-generator/:gameId", errorHandler(getGeneratorGameId)) - .get("/discord/contributors", errorHandler(getContributors)) - .all("*", (): Response => { - return new Response( - JSON.stringify({ - success: false, - status: "error", - error: "404 Not Found", - }), - { - headers: responseHeaders, - } - ); - }); - -addEventListener("fetch", (event: FetchEvent) => { - event.respondWith(router.handle(event.request)); -}); - -export { router }; diff --git a/src/index.ts b/src/index.ts index 93b077a..6ca0601 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,64 @@ -import { router } from "@/handler"; +import { OpenAPIHono } from "@hono/zod-openapi" +import { apiReference } from "@scalar/hono-api-reference" +import { prettyJSON } from "hono/pretty-json" +import BaseRoutes from "@/v2/routes/handler" +import { CustomCSS, OpenAPIConfig } from "./openapi/config" +import { cors } from "hono/cors" +import { csrf } from "hono/csrf" +import { rateLimit } from "./v2/middleware/ratelimit/limiter" -export default { - fetch: router.handle, -}; +// this is required for the rate limiter to work +export { RateLimiter } from "@/v2/middleware/ratelimit/ratelimit.do" + +const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +app.use("*", rateLimit(60, 100)) +app.use("*", csrf({ origin: "*" })) +app.use("*", prettyJSON({ space: 4 })) +app.use( + "*", + cors({ + // todo(dromzeh): this should be set dependant on ENV, PROD or DEV w/ next() for context + origin: "https://staging.wanderer.moe/", + credentials: true, + }) +) + +// openapi config +app.doc("/openapi", OpenAPIConfig) + +app.get( + "/docs", + apiReference({ + spec: { + url: "/openapi", + }, + customCss: CustomCSS, + }) +) + +// v2 API routes +app.route("/v2", BaseRoutes) + +app.notFound((ctx) => { + return ctx.json( + { + success: false, + message: "Not Found", + }, + 404 + ) +}) + +app.onError((err, ctx) => { + console.error(err) + return ctx.json( + { + success: false, + message: "Internal Server Error", + }, + 500 + ) +}) + +export default app diff --git a/src/lib/d1/checkRow.ts b/src/lib/d1/checkRow.ts deleted file mode 100644 index 629eebe..0000000 --- a/src/lib/d1/checkRow.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { rename } from "@/lib/helpers/rename"; - -export const checkRow = async ( - db: D1Database, - gameId: string, - asset: string -): Promise => { - const tableName: string = rename(gameId); - const location: string = rename(asset); - - const row: D1Result<{ requests: number }> = await db - .prepare(`SELECT requests FROM ${tableName} WHERE location = ?`) - .bind(location) - .all(); - - if (Array.isArray(row) && row.length === 0) { - await db - .prepare( - `INSERT INTO ${tableName} (location, requests) VALUES (?, 0)` - ) - .bind(location) - .run(); - } - - await db - .prepare( - `UPDATE ${tableName} SET requests = requests + 1 WHERE location = ?` - ) - .bind(location) - .run(); -}; diff --git a/src/lib/d1/checkTable.ts b/src/lib/d1/checkTable.ts deleted file mode 100644 index 22534ee..0000000 --- a/src/lib/d1/checkTable.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { rename } from "@/lib/helpers/rename"; - -export const checkTable = async ( - db: D1Database, - gameId: string -): Promise => { - const tableName: string = rename(gameId); - await db - .prepare( - `CREATE TABLE IF NOT EXISTS ${tableName} (location TEXT, requests INTEGER)` - ) - .run(); -}; diff --git a/src/lib/d1/getAssetRequests.ts b/src/lib/d1/getAssetRequests.ts deleted file mode 100644 index bbcfa26..0000000 --- a/src/lib/d1/getAssetRequests.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { rename } from "@/lib/helpers/rename"; - -export const getAssetRequests = async ( - db: D1Database, - gameId: string, - asset: string -): Promise => { - const tableName: string = rename(gameId); - const location: string = rename(asset); - - const requests: { results?: { requests: number }[] } = await db - .prepare(`SELECT requests FROM ${tableName} WHERE location = ?`) - .bind(location) - .all(); - - if (requests.results?.length === 0) { - await db - .prepare( - `INSERT INTO ${tableName} (location, requests) VALUES (?, 0)` - ) - .bind(location) - .run(); - } - - return requests?.results[0]?.requests ?? 0; -}; diff --git a/src/lib/helpers/rename.ts b/src/lib/helpers/rename.ts deleted file mode 100644 index b111207..0000000 --- a/src/lib/helpers/rename.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const rename = (name: string): string => { - return name.replace(/-/g, "_"); -}; diff --git a/src/lib/listBucket.ts b/src/lib/listBucket.ts deleted file mode 100644 index 7d1827d..0000000 --- a/src/lib/listBucket.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const listBucket = async (bucket, options) => { - const files = await bucket.list(options); - return files; -}; diff --git a/src/lib/types/asset.ts b/src/lib/types/asset.ts deleted file mode 100644 index f511ac0..0000000 --- a/src/lib/types/asset.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Image { - name: string; - nameWithExtension: string; - path: string; - uploaded: number; - size: number; -} diff --git a/src/lib/types/discord.ts b/src/lib/types/discord.ts deleted file mode 100644 index a0bc932..0000000 --- a/src/lib/types/discord.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface Contributor { - id: string; - username: string; - globalname: string | null; - avatar: string; - roles: string[]; -} - -export interface GuildMember { - roles: string[]; - user: { - id: string; - username: string; - global_name: string | null; - avatar: string; - }; -} diff --git a/src/lib/types/game.ts b/src/lib/types/game.ts deleted file mode 100644 index eaad28c..0000000 --- a/src/lib/types/game.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Subfolder { - name: string; - path: string; - fileCount: number; - lastUploaded: number; -} - -export interface Game { - name: string; - path: string; - tags: string[]; - totalFiles: number; - lastUploaded: number; - subfolders: Subfolder[]; -} - -export interface Location { - name: string; - path: string; - fileCount: number; - popularity: number; - lastUploaded: number; -} diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts deleted file mode 100644 index 0f829e9..0000000 --- a/src/middleware/errorHandler.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; - -export const errorHandler = - (handler: (request: Request, env: Env) => Promise) => - async (request: Request, env: Env): Promise => { - try { - return await handler(request, env); - } catch (error) { - console.error(error); - return new Response( - JSON.stringify({ - success: false, - status: "error", - error: "500 Internal Server Error", - }), - { - headers: responseHeaders, - } - ); - } - }; diff --git a/src/middleware/unwantedPrefixes.ts b/src/middleware/unwantedPrefixes.ts deleted file mode 100644 index db27045..0000000 --- a/src/middleware/unwantedPrefixes.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const unwantedPrefixes: string[] = [ - "other/", - "locales/", - "oc-generator/", -]; diff --git a/src/openapi/config.ts b/src/openapi/config.ts new file mode 100644 index 0000000..fbab324 --- /dev/null +++ b/src/openapi/config.ts @@ -0,0 +1,96 @@ +export const OpenAPIConfig = { + openapi: "3.1.0", + info: { + version: "2.0.0", + title: "api.wanderer.moe", + description: `Public Zod OpenAPI documentation for wanderer.moe's API. This API is used to power the website & all routes are documented. Rate limits are imposed to prevent abuse.`, + license: { + name: "GNU General Public License v3.0", + url: "https://www.gnu.org/licenses/gpl-3.0.en.html", + }, + contact: { + url: "https://wanderer.moe", + name: "wanderer.moe", + }, + }, +} + +export const CustomCSS: string = ` +:root { + --scalar-font: 'Inter'; + } + /* basic theme */ + .light-mode { + --scalar-background-1: #fff; + --scalar-background-2: #f5f6f8; + --scalar-background-3: #e7e7e7; + + --scalar-color-1: #2a2f45; + --scalar-color-2: #757575; + --scalar-color-3: #8e8e8e; + + --scalar-color-accent: #EA8FEA; + --scalar-background-accent: #8ab4f81f; + + --scalar-border-color: rgba(215, 215, 206, 0.5); + } + .dark-mode { + --scalar-background-1: #09090B; + --scalar-background-2: #111113; + --scalar-background-3: #19191A; + + --scalar-color-1: rgba(255, 255, 255, 0.9); + --scalar-color-2: rgba(255, 255, 255, 0.62); + --scalar-color-3: rgba(255, 255, 255, 0.44); + + --scalar-color-accent: #EA8FEA; + --scalar-background-accent: #8ab4f81f; + + --scalar-border-color: rgba(255, 255, 255, 0.12); + } + /* Document header */ + .light-mode .t-doc__header, + .dark-mode .t-doc__header { + --header-background-1: var(--scalar-background-1); + --header-border-color: var(--scalar-border-color); + --header-color-1: var(--scalar-color-1); + --header-color-2: var(--scalar-color-2); + --header-call-to-action-color: var(--scalar-color-accent); + } + /* Document Sidebar */ + .light-mode .t-doc__sidebar, + .dark-mode .t-doc__sidebar { + --scalar-sidebar-background-1: var(--scalar-background-1); + --scalar-sidebar-color-1: var(--scalar-color-1); + --scalar-sidebar-color-2: var(--scalar-color-2); + --scalar-sidebar-border-color: var(--scalar-border-color); + + --scalar-sidebar-item-hover-background: var(--scalar-background-3); + --scalar-sidebar-item-hover-color: currentColor; + + --scalar-sidebar-item-active-background: var(--scalar-background-accent); + --scalar-sidebar-color-active: var(--scalar-color-accent); + + --scalar-sidebar-search-background: var(--scalar-background-1); + --scalar-sidebar-search-color: var(--scalar-color-3); + --scalar-sidebar-search-border-color: var(--scalar-border-color); + } + + /* advanced */ + .light-mode { + --scalar-color-green: #C8FFD4; + --scalar-color-red: #FF8080; + --scalar-color-yellow: #edbe20; + --scalar-color-blue: #B8E8FC; + --scalar-color-orange: #FFCF96; + --scalar-color-purple: #EA8FEA; + } + .dark-mode { + --scalar-color-green: #C8FFD4; + --scalar-color-red: #FF8080; + --scalar-color-yellow: #ffc90d; + --scalar-color-blue: #B8E8FC; + --scalar-color-orange: #FFCF96; + --scalar-color-purple: #EA8FEA; + } +` diff --git a/src/routes/discord/contributors.ts b/src/routes/discord/contributors.ts deleted file mode 100644 index 5259c51..0000000 --- a/src/routes/discord/contributors.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { roles, guildId } from "@/lib/discord"; -import type { Contributor, GuildMember } from "@/lib/types/discord"; - -export const getContributors = async ( - request: Request, - env: Env -): Promise => { - const members: Contributor[] = []; - - let after: string | null = null; - let fetchUsers = true; - - while (fetchUsers) { - const response = await fetch( - `https://discord.com/api/guilds/${guildId}/members?limit=1000${ - after ? `&after=${after}` : "" - }`, - { - headers: { - Authorization: `Bot ${env.DISCORD_TOKEN}`, - }, - } - ); - - const guildMembers: GuildMember[] = await response.json(); - - const filteredMembers: GuildMember[] = guildMembers.filter((member) => { - return member.roles.some((role) => - Object.keys(roles).includes(role) - ); - }); - - const contributors: Contributor[] = filteredMembers.map((member) => { - const rolesArray = member.roles - .map((role) => roles[role]) - .filter((role) => role); - - return { - id: member.user.id, - username: member.user.username, - globalname: member.user.global_name || null, - avatar: `https://cdn.discordapp.com/avatars/${member.user.id}/${member.user.avatar}.webp`, - roles: rolesArray, - }; - }); - - members.push(...contributors); - - if ( - !guildMembers.length || - !guildMembers[guildMembers.length - 1].user - ) { - fetchUsers = false; - } - - after = guildMembers[guildMembers.length - 1]?.user?.id; - } - - return new Response( - JSON.stringify({ - success: true, - status: "ok", - path: `/discord/contributors`, - contributors: members, - }), - { - headers: responseHeaders, - } - ); -}; diff --git a/src/routes/games/getAsset.ts b/src/routes/games/getAsset.ts deleted file mode 100644 index 1a1a817..0000000 --- a/src/routes/games/getAsset.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { listBucket } from "@/lib/listBucket"; -import { checkTable } from "@/lib/d1/checkTable"; -import { checkRow } from "@/lib/d1/checkRow"; -import type { Image } from "@/lib/types/asset"; - -export const getAsset = async ( - request: Request, - env: Env -): Promise => { - const url = new URL(request.url); - const pathSegments = url.pathname - .split("/") - .filter((segment) => segment !== ""); - - if (pathSegments.length !== 3 || pathSegments[0] !== "game") { - return new Response( - JSON.stringify({ - success: false, - status: "error", - path: url.pathname, - error: "Invalid URL path", - }), - { - headers: responseHeaders, - } - ); - } - - const [, gameId, asset] = pathSegments; - - const cacheKey = new Request(url.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (response) { - return response; - } - - const files = await listBucket(env.bucket, { - prefix: `${gameId}/${asset}/`, - }); - - if (files.objects.length === 0) { - response = new Response( - JSON.stringify({ - success: false, - status: "error", - path: `/game/${gameId}/${asset}`, - error: "404 Not Found", - }), - { - headers: responseHeaders, - } - ); - } else { - const images: Image[] = files.objects.map((file) => ({ - name: file.key.split("/").pop().replace(".png", ""), - nameWithExtension: file.key.split("/").pop(), - path: `https://cdn.wanderer.moe/${file.key}`, - uploaded: file.uploaded, - size: file.size, - })); - - const lastUploaded = images.sort((a, b) => b.uploaded - a.uploaded)[0]; - - try { - await checkTable(env.database, gameId); - await checkRow(env.database, gameId, asset); - } catch (e) { - console.error(e); - } - - response = new Response( - JSON.stringify({ - success: true, - status: "ok", - path: `/game/${gameId}/${asset}`, - game: gameId, - asset, - lastUploaded, - images, - }), - { - headers: responseHeaders, - } - ); - - await cache.put(cacheKey, response.clone()); - } - - return response; -}; diff --git a/src/routes/games/getGameId.ts b/src/routes/games/getGameId.ts deleted file mode 100644 index c9434a5..0000000 --- a/src/routes/games/getGameId.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { listBucket } from "@/lib/listBucket"; -import { checkTable } from "@/lib/d1/checkTable"; -import { getAssetRequests } from "@/lib/d1/getAssetRequests"; -import type { Location } from "@/lib/types/game"; - -export const getGameId = async ( - request: Request, - env: Env -): Promise => { - const url = new URL(request.url); - const pathSegments = url.pathname - .split("/") - .filter((segment) => segment !== ""); - - if (pathSegments.length !== 2 || pathSegments[0] !== "game") { - return new Response( - JSON.stringify({ - success: false, - status: "error", - path: url.pathname, - error: "Invalid URL path", - }), - { - headers: responseHeaders, - } - ); - } - - const [, gameId] = pathSegments; - - const cacheKey = new Request(url.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (response) { - return response; - } - - const files = await listBucket(env.bucket, { - prefix: `${gameId}/`, - delimiter: "/", - }); - - const locations = files.delimitedPrefixes.map(async (file) => { - const subfolderFiles = await listBucket(env.bucket, { - prefix: `${file}`, - }); - - const fileCount = subfolderFiles.objects.length; - const lastUploaded = subfolderFiles.objects.reduce( - (prev, current) => { - const prevDate = new Date(prev.uploaded); - const currentDate = new Date(current.uploaded); - return prevDate > currentDate ? prev : current; - }, - { uploaded: 0 } - ); - - const name = file.replace(`${gameId}/`, "").replace("/", ""); - - try { - await checkTable(env.database, gameId); - } catch (e) { - console.error(e); - } - - let popularity = 0; - try { - const requestsCount = await getAssetRequests( - env.database, - gameId, - name - ); - popularity = requestsCount; - } catch (e) { - console.error(e); - } - - return { - name, - path: `https://api.wanderer.moe/game/${gameId}/${file - .replace(`${gameId}/`, "") - .replace("/", "")}`, - fileCount, - popularity, - lastUploaded: lastUploaded.uploaded, - } as Location; - }); - - const locationsWithFileCount = await Promise.all(locations); - locationsWithFileCount.sort((a, b) => b.popularity - a.popularity); - - locationsWithFileCount.forEach((location, index) => { - location.popularity = index + 1; - }); - - const totalFiles = locationsWithFileCount.reduce( - (total, location) => total + location.fileCount, - 0 - ); - - if (files.objects.length === 0) { - response = new Response( - JSON.stringify({ - success: false, - status: "error", - path: `/game/${gameId}`, - error: "404 Not Found", - }), - { - headers: responseHeaders, - } - ); - } else { - const lastUploaded = locationsWithFileCount.reduce( - (prev, current) => { - const prevDate = new Date(prev.lastUploaded); - const currentDate = new Date(current.lastUploaded); - return prevDate > currentDate ? prev : current; - }, - { lastUploaded: 0 } - ); - - response = new Response( - JSON.stringify({ - success: true, - status: "ok", - path: `/game/${gameId}`, - game: gameId, - totalFiles, - lastUploaded: lastUploaded.lastUploaded, - locations: locationsWithFileCount, - }), - { - headers: responseHeaders, - } - ); - - await cache.put(cacheKey, response.clone()); - } - - return response; -}; diff --git a/src/routes/games/getGames.ts b/src/routes/games/getGames.ts deleted file mode 100644 index cdbdf13..0000000 --- a/src/routes/games/getGames.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { listBucket } from "@/lib/listBucket"; -import { unwantedPrefixes } from "@/middleware/unwantedPrefixes"; -import type { Subfolder, Game } from "@/lib/types/game"; - -export const getGames = async ( - request: Request, - env: Env -): Promise => { - const url = new URL(request.url); - const cacheKey = new Request(url.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (response) { - return response; - } - - const files = await listBucket(env.bucket, { - prefix: "", - delimiter: "/", - }); - - const rootLocations = files.delimitedPrefixes - .filter((game) => !unwantedPrefixes.includes(game)) - .map(async (game) => { - const gameFiles = await listBucket(env.bucket, { - prefix: `${game}`, - delimiter: "/", - }); - - const tags = gameFiles.delimitedPrefixes.some((subfolder) => - subfolder.includes("sheets") - ) - ? ["Has Sheets"] - : []; - - const subfolders = await Promise.all( - gameFiles.delimitedPrefixes.map(async (subfolder) => { - const subfolderFiles = await listBucket(env.bucket, { - prefix: `${subfolder}`, - }); - const lastUploaded = subfolderFiles.objects.reduce( - (prev, current) => { - const prevDate = new Date(prev.uploaded); - const currentDate = new Date(current.uploaded); - return prevDate > currentDate ? prev : current; - }, - { uploaded: 0 } - ); - return { - name: subfolder.replace(game, "").replace("/", ""), - path: `https://api.wanderer.moe/game/${subfolder}`, - fileCount: subfolderFiles.objects.length, - lastUploaded: lastUploaded.uploaded, - } as Subfolder; - }) - ); - - const totalFiles = subfolders.reduce( - (total, subfolder) => total + subfolder.fileCount, - 0 - ); - - const lastUploaded = subfolders.reduce( - (prev, current) => { - const prevDate = new Date(prev.lastUploaded); - const currentDate = new Date(current.lastUploaded); - return prevDate > currentDate ? prev : current; - }, - { lastUploaded: 0 } - ); - - return { - name: game.replace("/", ""), - path: `https://api.wanderer.moe/game/${game}`, - tags, - totalFiles, - lastUploaded: lastUploaded.lastUploaded, - subfolders, - } as Game; - }); - - const games = await Promise.all(rootLocations); - - response = new Response( - JSON.stringify({ - success: true, - status: "ok", - path: "/games", - games, - }), - { - headers: responseHeaders, - } - ); - - await cache.put(cacheKey, response.clone()); - - return response; -}; diff --git a/src/routes/index.ts b/src/routes/index.ts deleted file mode 100644 index 638b7ba..0000000 --- a/src/routes/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; - -const routes: string[] = [ - "https://api.wanderer.moe/games", - "https://api.wanderer.moe/game/{gameId}", - "https://api.wanderer.moe/game/{gameId}/{asset}", - "https://api.wanderer.moe/oc-generators", - "https://api.wanderer.moe/oc-generator/{gameId}", -]; - -export const index = async (): Promise => { - return new Response( - JSON.stringify({ - success: true, - status: "ok", - path: "/", - routes, - }), - { - headers: responseHeaders, - } - ); -}; diff --git a/src/routes/oc-generators/getGameId.ts b/src/routes/oc-generators/getGameId.ts deleted file mode 100644 index 2217095..0000000 --- a/src/routes/oc-generators/getGameId.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { listBucket } from "@/lib/listBucket"; - -export const getGeneratorGameId = async ( - request: Request, - env: Env -): Promise => { - const url = new URL(request.url); - const pathSegments = url.pathname - .split("/") - .filter((segment) => segment !== ""); - - if (pathSegments.length !== 2 || pathSegments[0] !== "oc-generator") { - return new Response( - JSON.stringify({ - success: false, - status: "error", - path: url.pathname, - error: "Invalid URL path", - }), - { - headers: responseHeaders, - } - ); - } - - const [, gameId] = pathSegments; - - const cacheKey = new Request(url.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (response) { - return response; - } - - const files = await listBucket(env.bucket, { - prefix: `oc-generator/${gameId}/list.json`, - }); - - if (files.objects.length === 0) { - response = new Response( - JSON.stringify({ - success: false, - status: "error", - error: "404 Not Found", - }), - { - headers: responseHeaders, - } - ); - } else { - response = new Response( - JSON.stringify({ - success: true, - status: "ok", - path: `/oc-generator/${gameId}`, - game: gameId, - json: `https://cdn.wanderer.moe/oc-generator/${gameId}/list.json`, - }), - { - headers: responseHeaders, - } - ); - } - - await cache.put(cacheKey, response.clone()); - - return response; -}; diff --git a/src/routes/oc-generators/getGenerators.ts b/src/routes/oc-generators/getGenerators.ts deleted file mode 100644 index 710d2a6..0000000 --- a/src/routes/oc-generators/getGenerators.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { responseHeaders } from "@/lib/responseHeaders"; -import { listBucket } from "@/lib/listBucket"; -import type { Location } from "@/lib/types/game"; - -export const getGenerators = async ( - request: Request, - env: Env -): Promise => { - const url = new URL(request.url); - const cacheKey = new Request(url.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (response) { - return response; - } - - const files = await listBucket(env.bucket, { - prefix: "oc-generator/", - delimiter: "/", - }); - - const locations: Location[] = files.delimitedPrefixes.map((file) => ({ - name: file.replace("oc-generator/", "").replace("/", ""), - path: `https://api.wanderer.moe/oc-generator/${file - .replace("oc-generator/", "") - .replace("/", "")}`, - })); - - response = new Response( - JSON.stringify({ - success: true, - status: "ok", - path: "/oc-generators", - locations, - }), - { - headers: responseHeaders, - } - ); - - await cache.put(cacheKey, response.clone()); - - return response; -}; diff --git a/src/scripts/migrate/migrate.mts b/src/scripts/migrate/migrate.mts new file mode 100644 index 0000000..3f33827 --- /dev/null +++ b/src/scripts/migrate/migrate.mts @@ -0,0 +1,62 @@ +import { drizzle as drizzleORM } from "drizzle-orm/libsql" +import { migrate } from "drizzle-orm/libsql/migrator" +import { createClient } from "@libsql/client" +import dotenv from "dotenv" + +dotenv.config({ path: ".dev.vars" }) + +const { + TURSO_DATABASE_AUTH_TOKEN, + TURSO_DATABASE_URL, + ENVIRONMENT, + TURSO_DEV_DATABASE_URL = "http://127.0.0.1:8080", +} = process.env + +const isDev = ENVIRONMENT === "DEV" +const DATABASE_URL = isDev ? TURSO_DEV_DATABASE_URL : TURSO_DATABASE_URL +const AUTH_TOKEN = isDev ? undefined : TURSO_DATABASE_AUTH_TOKEN + +async function main() { + logMigrationDetails() + + if (!DATABASE_URL) { + throw new Error("DATABASE_URL is not defined!") + } else if (!AUTH_TOKEN && !isDev) { + throw new Error("AUTH_TOKEN is not defined!") + } + + await delayBeforeMigration(isDev ? 1000 : 10000) + + const client = createDatabaseClient(DATABASE_URL, AUTH_TOKEN) + const db = drizzleORM(client) + + console.log("[MIGRATION] Migrating database...") + await migrate(db, { migrationsFolder: "./src/v2/db/migrations" }) + console.log("[MIGRATION] Migrations complete!") + + process.exit(0) +} + +function logMigrationDetails() { + console.log("[MIGRATION] DEV:", isDev) + console.log(`[MIGRATION] URL: ${DATABASE_URL}`) +} + +function delayBeforeMigration(waitTime: number) { + console.log(`[MIGRATION] Waiting ${waitTime}ms until migration...`) + return new Promise((resolve) => setTimeout(resolve, waitTime)) +} + +function createDatabaseClient(url: string, authToken: string | undefined) { + console.log("[MIGRATION] Connecting to the database client...") + const client = createClient({ url, authToken }) + console.log( + "[MIGRATION] Connected to the database client & initialized drizzle-orm instance" + ) + return client +} + +main().catch((err) => { + console.error(`[MIGRATION] Error: ${err}`) + process.exit(1) +}) diff --git a/src/scripts/seed/seed.ts b/src/scripts/seed/seed.ts new file mode 100644 index 0000000..7a20ad1 --- /dev/null +++ b/src/scripts/seed/seed.ts @@ -0,0 +1,568 @@ +import { drizzle as drizzleORM } from "drizzle-orm/libsql" +import { createClient } from "@libsql/client" +import { + asset, + assetCategory, + assetTag, + assetTagAsset, + authCredentials, + authUser, + game, + gameAssetCategory, + userCollection, + userCollectionAsset, + userFollowing, + requestFormUpvotes, + requestForm, + assetComments, + assetCommentsLikes, +} from "@/v2/db/schema" +import { Scrypt } from "lucia" +import "dotenv/config" +import { generateID } from "@/v2/lib/oslo" + +async function main() { + console.log("[SEED] Connecting to database client...") + + // this script will only be ran in local dev so we can hardcode the url here + const client = createClient({ + url: "http://127.0.0.1:8080", + }) + const db = drizzleORM(client) + console.log( + "[SEED] Connected to database client & initialized drizzle-orm instance" + ) + + console.log("[SEED] Seeding database...\n") + + console.log("[SEED] [authUser] Seeding users...") + const newUsers = await db + .insert(authUser) + .values([ + { + username: "adminuser", + email: "admin@wanderer.moe", + emailVerified: 1, + usernameColour: "#84E6F8", + bio: "test bio", + role: "creator", + isContributor: true, + plan: "supporter", + }, + { + username: "testuser2", + email: "testuser2@wanderer.moe", + emailVerified: 1, + bio: "test bio 2", + pronouns: "he/him/his", + role: "uploader", + isContributor: false, + }, + { + username: "testuser3", + email: "testuser3@wanderer.moe", + emailVerified: 1, + bio: "test bio 3", + role: "uploader", + isContributor: false, + plan: "supporter", + }, + ]) + .returning() + console.log(`[SEED] [authUser] inserted ${newUsers.length} rows\n`) + + const devAdminPassword = "password123" + + console.log( + `[SEED] [userCredentials] Seeding user login for admin with password ${devAdminPassword}...` + ) + + const newCredentials = await db + .insert(authCredentials) + .values({ + userId: newUsers[0].id, + hashedPassword: await new Scrypt().hash(devAdminPassword), + }) + .returning() + + console.log( + `[SEED] [userCredentials] inserted ${newCredentials.length} rows\n` + ) + + console.log("[SEED] [userFollowing] Seeding user following...") + const newuserFollowing = await db + .insert(userFollowing) + .values([ + { + followerId: newUsers[0].id, + followingId: newUsers[1].id, + }, + { + followerId: newUsers[1].id, + followingId: newUsers[0].id, + }, + { + followerId: newUsers[0].id, + followingId: newUsers[2].id, + }, + ]) + .returning() + console.log( + `[SEED] [userFollowing] inserted ${newuserFollowing.length} rows\n` + ) + + console.log("[SEED] [requestForm] Seeding request forms...") + const newRequestForms = await db + .insert(requestForm) + .values([ + { + userId: newUsers[0].id, + title: "test request", + area: "game", + description: "test description", + }, + { + userId: newUsers[1].id, + title: "test request 2", + area: "game", + description: "test description 2", + }, + ]) + .returning() + + console.log( + `[SEED] [requestForm] inserted ${newRequestForms.length} rows\n` + ) + + console.log("[SEED] [requestFormUpvotes] Seeding request form upvotes...") + const newRequestFormUpvotes = await db + .insert(requestFormUpvotes) + .values([ + { + requestFormId: newRequestForms[0].id, + userId: newUsers[1].id, + }, + { + requestFormId: newRequestForms[1].id, + userId: newUsers[0].id, + }, + ]) + .returning() + + console.log( + `[SEED] [requestFormUpvotes] inserted ${newRequestFormUpvotes.length} rows\n` + ) + + console.log("[SEED] [assetTag] Seeding asset tags...") + const newAssetTags = await db + .insert(assetTag) + .values([ + { + id: "test-tag-1", + name: "test-tag-1", + formattedName: "Test Tag 1", + lastUpdated: new Date().toISOString(), + }, + { + id: "test-tag-2", + name: "test-tag-2", + formattedName: "Test Tag 2", + lastUpdated: new Date().toISOString(), + }, + { + id: "test-tag-3", + name: "test-tag-3", + formattedName: "Test Tag 3", + lastUpdated: new Date().toISOString(), + }, + ]) + .returning() + console.log(`[SEED] [assetTag] inserted ${newAssetTags.length} rows\n`) + + console.log("[SEED] [game] Seeding games...") + const newGames = await db + .insert(game) + .values([ + { + id: "test-game-1", + name: "test-game-1", + formattedName: "Test Game 1", + lastUpdated: new Date().toISOString(), + }, + { + id: "test-game-2", + name: "test-game-2", + formattedName: "Test Game 2", + lastUpdated: new Date().toISOString(), + }, + { + id: "test-game-3", + name: "test-game-3", + formattedName: "Test Game 3", + lastUpdated: new Date().toISOString(), + }, + ]) + .returning() + console.log(`[SEED] [game] inserted ${newGames.length} rows\n`) + + console.log("[SEED] [assetCategory] Seeding asset categories...") + const newAssetCategories = await db + .insert(assetCategory) + .values([ + { + id: "test-category-1", + name: "test-category-1", + formattedName: "Test Category 1", + lastUpdated: new Date().toISOString(), + }, + { + id: "test-category-2", + name: "test-category-2", + formattedName: "Test Category 2", + lastUpdated: new Date().toISOString(), + }, + ]) + .returning() + console.log( + `[SEED] [assetCategory] inserted ${newAssetCategories.length} rows\n` + ) + + console.log( + "[SEED] [gameAssetCategory] Linking games to asset categories..." + ) + const newGameAssetCategory = await db + .insert(gameAssetCategory) + .values([ + { + gameId: newGames[0].id, + assetCategoryId: newAssetCategories[0].id, + }, + { + gameId: newGames[1].id, + assetCategoryId: newAssetCategories[1].id, + }, + { + gameId: newGames[0].id, + assetCategoryId: newAssetCategories[1].id, + }, + ]) + .returning() + console.log( + `[SEED] [gameAssetCategory] inserted ${newGameAssetCategory.length} rows\n` + ) + + console.log("[SEED] [asset] Seeding assets...") + + const assetIDArray = new Array(6).fill(null).map(() => { + return generateID() + }) + + const newAssets = await db + .insert(asset) + .values([ + { + id: assetIDArray[0], + name: "test-asset", + extension: "image/png", + gameId: "test-game-1", + assetCategoryId: "test-category-2", + url: `/asset/${assetIDArray[0]}.png`, + status: "approved", + uploadedById: newUsers[0].id, + uploadedByName: newUsers[0].username, + viewCount: 1337, + downloadCount: 1337, + fileSize: 40213, + width: 512, + height: 512, + }, + { + id: assetIDArray[1], + name: "test-asset-2", + extension: "image/png", + gameId: "test-game-2", + assetCategoryId: "test-category-2", + url: `/asset/${assetIDArray[1]}.png`, + status: "approved", + uploadedById: newUsers[1].id, + uploadedByName: newUsers[1].username, + viewCount: 1337, + downloadCount: 1337, + fileSize: 40213, + width: 1920, + height: 1080, + }, + { + id: assetIDArray[2], + name: "test-asset-3", + extension: "image/png", + gameId: "test-game-1", + assetCategoryId: "test-category-1", + url: `/asset/${assetIDArray[2]}.png`, + status: "approved", + uploadedById: newUsers[1].id, + uploadedByName: newUsers[1].username, + viewCount: 1337, + downloadCount: 1337, + fileSize: 40213, + width: 1080, + height: 1920, + }, + { + id: assetIDArray[3], + name: "test-asset-4", + extension: "image/png", + gameId: "test-game-1", + assetCategoryId: "test-category-2", + url: `/asset/${assetIDArray[3]}.png`, + status: "approved", + uploadedById: newUsers[1].id, + uploadedByName: newUsers[1].username, + viewCount: 1337, + downloadCount: 1337, + fileSize: 40213, + width: 1920, + height: 1080, + }, + { + id: assetIDArray[4], + name: "test-asset-5", + extension: "image/png", + gameId: "test-game-1", + assetCategoryId: "test-category-2", + url: `/asset/${assetIDArray[4]}.png`, + status: "approved", + uploadedById: newUsers[2].id, + uploadedByName: newUsers[2].username, + viewCount: 1337, + downloadCount: 1337, + }, + { + id: assetIDArray[5], + name: "test-asset-5", + extension: "image/png", + gameId: "test-game-3", + assetCategoryId: "test-category-2", + url: `/asset/${assetIDArray[5]}.png`, + status: "approved", + uploadedById: newUsers[2].id, + uploadedByName: newUsers[2].username, + viewCount: 1337, + downloadCount: 1337, + }, + ]) + .returning() + console.log(`[SEED] [asset] inserted ${newAssets.length} rows\n`) + + console.log("[SEED] [assetTagAsset] Linking assets to asset tags...") + const newAssetTagAsset = await db + .insert(assetTagAsset) + .values([ + { + assetId: newAssets[0].id, + assetTagId: "test-tag-1", + }, + { + assetId: newAssets[0].id, + assetTagId: "test-tag-2", + }, + { + assetId: newAssets[1].id, + assetTagId: "test-tag-1", + }, + { + assetId: newAssets[2].id, + assetTagId: "test-tag-1", + }, + { + assetId: newAssets[2].id, + assetTagId: "test-tag-2", + }, + { + assetId: newAssets[3].id, + assetTagId: "test-tag-3", + }, + { + assetId: newAssets[4].id, + assetTagId: "test-tag-3", + }, + { + assetId: newAssets[5].id, + assetTagId: "test-tag-3", + }, + { + assetId: newAssets[5].id, + assetTagId: "test-tag-2", + }, + ]) + .returning() + console.log( + `[SEED] [assetTagAsset] inserted ${newAssetTagAsset.length} rows\n` + ) + + console.log("[SEED] [assetComments] Seeding asset comments...") + const newAssetComments = await db + .insert(assetComments) + .values([ + { + assetId: newAssets[0].id, + commentedById: newUsers[0].id, + comment: "test comment", + }, + { + assetId: newAssets[0].id, + commentedById: newUsers[1].id, + comment: "test comment 2", + }, + { + assetId: newAssets[1].id, + commentedById: newUsers[0].id, + comment: "test comment 3", + }, + ]) + .returning() + console.log( + `[SEED] [assetComments] inserted ${newAssetComments.length} rows\n` + ) + + console.log( + "[SEED] [assetComments] Seeding replies to comments [self ref]..." + ) + const newAssetCommentsReplies = await db + .insert(assetComments) + .values([ + { + commentedById: newUsers[1].id, + comment: "test comment reply", + parentCommentId: newAssetComments[0].id, + }, + { + commentedById: newUsers[0].id, + comment: "test comment reply 2", + parentCommentId: newAssetComments[1].id, + }, + { + commentedById: newUsers[1].id, + comment: "test comment reply 3", + parentCommentId: newAssetComments[2].id, + }, + ]) + .returning() + console.log( + `[SEED] [assetComments] inserted ${newAssetCommentsReplies.length} rows\n` + ) + + const newAssetCommentsRepliesReplies = await db + .insert(assetComments) + .values([ + { + commentedById: newUsers[0].id, + comment: "test comment reply reply", + parentCommentId: newAssetCommentsReplies[0].id, + }, + { + commentedById: newUsers[1].id, + comment: "test comment reply reply 2", + parentCommentId: newAssetCommentsReplies[1].id, + }, + { + commentedById: newUsers[0].id, + comment: "test comment reply reply 3", + parentCommentId: newAssetCommentsReplies[2].id, + }, + ]) + .returning() + console.log( + `[SEED] [assetComments] inserted ${newAssetCommentsRepliesReplies.length} rows\n` + ) + + console.log("[SEED] [assetCommentsLikes] Seeding asset comments likes...") + const newAssetCommentsLikes = await db + .insert(assetCommentsLikes) + .values([ + { + commentId: newAssetComments[0].id, + likedById: newUsers[1].id, + }, + { + commentId: newAssetComments[1].id, + likedById: newUsers[0].id, + }, + { + commentId: newAssetComments[2].id, + likedById: newUsers[1].id, + }, + { + commentId: newAssetCommentsReplies[0].id, + likedById: newUsers[0].id, + }, + { + commentId: newAssetCommentsReplies[1].id, + likedById: newUsers[1].id, + }, + { + commentId: newAssetCommentsReplies[2].id, + likedById: newUsers[0].id, + }, + { + commentId: newAssetCommentsRepliesReplies[0].id, + likedById: newUsers[1].id, + }, + { + commentId: newAssetCommentsRepliesReplies[1].id, + likedById: newUsers[0].id, + }, + { + commentId: newAssetCommentsRepliesReplies[2].id, + likedById: newUsers[1].id, + }, + ]) + .returning() + console.log( + `[SEED] [assetCommentsLikes] inserted ${newAssetCommentsLikes.length} rows\n` + ) + + console.log("[SEED] [userCollection] Seeding user collections...") + const newUserCollections = await db + .insert(userCollection) + .values({ + name: "collection name", + description: "collection description", + userId: newUsers[0].id, + isPublic: true, // default to private + }) + .returning() + console.log( + `[SEED] [userCollection] inserted ${newUserCollections.length} rows\n` + ) + + console.log( + "[SEED] [userCollectionAsset] Linking user collections to assets..." + ) + const newUserCollectionAssets = await db + .insert(userCollectionAsset) + .values([ + { + collectionId: newUserCollections[0].id, + order: 0, + assetId: newAssets[0].id, + }, + { + collectionId: newUserCollections[0].id, + order: 1, + assetId: newAssets[1].id, + }, + ]) + .returning() + console.log( + `[SEED] [userCollectionAsset] inserted ${newUserCollectionAssets.length} rows\n` + ) + + console.log("[SEED] Seeded database successfully") + process.exit(0) +} + +main().catch((err) => { + console.error(`[SEED] Error: ${err}`) + process.exit(1) +}) diff --git a/src/v2/db/drizzle.ts b/src/v2/db/drizzle.ts new file mode 100644 index 0000000..c4be020 --- /dev/null +++ b/src/v2/db/drizzle.ts @@ -0,0 +1,33 @@ +// TODO(dromzeh): organize this, not a priority right now though +export const tableNames = { + asset: "asset", + authCredentials: "authCredentials", + authSession: "authSession", + authUser: "authUser", + userFollowing: "userFollowing", + userBlocked: "userBlocked", + gameAssetCategory: "gameAssetCategory", + assetLikes: "assetLikes", + assetComments: "assetComments", + assetCommentsLikes: "assetCommentsLikes", + userCollectionLikes: "userCollectionLikes", + userCollectionCollaborators: "userCollectionCollaborators", + game: "game", + gameLikes: "gameLikes", + stripeUser: "stripeUser", + assetExternalFile: "assetExternalFile", + assetTag: "assetTag", + assetTagLikes: "assetTagLikes", + assetTagAsset: "assetTagAsset", + emailVerificationToken: "emailVerificationToken", + passwordResetToken: "passwordResetToken", + assetCategory: "assetCategory", + assetCategoryLikes: "assetCategoryLikes", + userFavourite: "userFavourite", + userFavouriteAsset: "userFavouriteAsset", + userCollection: "userCollection", + userCollectionAsset: "userCollectionAsset", + socialsConnection: "socialsConnection", + requestForm: "requestForm", + requestFormUpvotes: "requestFormUpvotes", +} diff --git a/src/v2/db/migrations/0000_lazy_krista_starr.sql b/src/v2/db/migrations/0000_lazy_krista_starr.sql new file mode 100644 index 0000000..7707a9c --- /dev/null +++ b/src/v2/db/migrations/0000_lazy_krista_starr.sql @@ -0,0 +1,303 @@ +CREATE TABLE `asset` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `extension` text NOT NULL, + `game` text NOT NULL, + `asset_category` text NOT NULL, + `uploaded_by_id` text NOT NULL, + `uploaded_by_name` text NOT NULL, + `url` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `uploaded_date` text NOT NULL, + `asset_is_suggestive` integer DEFAULT false NOT NULL, + `comments_is_locked` integer DEFAULT false NOT NULL, + `view_count` integer DEFAULT 0 NOT NULL, + `download_count` integer DEFAULT 0 NOT NULL, + `file_size` integer DEFAULT 0 NOT NULL, + `width` integer DEFAULT 0 NOT NULL, + `height` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`game`) REFERENCES `game`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`asset_category`) REFERENCES `assetCategory`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`uploaded_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`uploaded_by_name`) REFERENCES `authUser`(`username`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `assetComments` ( + `comment_id` text PRIMARY KEY NOT NULL, + `asset_id` text, + `parent_comment_id` text, + `commented_by_id` text NOT NULL, + `comment` text NOT NULL, + `created_at` text NOT NULL, + `edited_at` text DEFAULT null, + FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`commented_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`parent_comment_id`) REFERENCES `assetComments`(`comment_id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `assetCommentsLikes` ( + `comment_id` text NOT NULL, + `liked_by_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`comment_id`) REFERENCES `assetComments`(`comment_id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`liked_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `assetExternalFile` ( + `id` text PRIMARY KEY NOT NULL, + `url` text NOT NULL, + `uploaded_by` text NOT NULL, + `uploaded_by_name` text NOT NULL, + `asset_id` text NOT NULL, + `uploaded_date` text NOT NULL, + `file_size` integer DEFAULT 0 NOT NULL, + `file_type` text NOT NULL, + FOREIGN KEY (`uploaded_by`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`uploaded_by_name`) REFERENCES `authUser`(`username`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `assetLikes` ( + `asset_id` text NOT NULL, + `liked_by_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`liked_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `assetCategory` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `formatted_name` text NOT NULL, + `last_updated` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `gameAssetCategory` ( + `game_id` text NOT NULL, + `asset_category_id` text NOT NULL, + FOREIGN KEY (`game_id`) REFERENCES `game`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`asset_category_id`) REFERENCES `assetCategory`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `game` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `formatted_name` text NOT NULL, + `possible_suggestive_content` integer DEFAULT false NOT NULL, + `last_updated` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `assetTag` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `formatted_name` text NOT NULL, + `last_updated` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `assetTagAsset` ( + `asset_tag_id` text NOT NULL, + `asset_id` text NOT NULL, + FOREIGN KEY (`asset_tag_id`) REFERENCES `assetTag`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `authCredentials` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `hashed_password` text, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `authUser` ( + `id` text PRIMARY KEY NOT NULL, + `avatar_url` text, + `banner_url` text, + `display_name` text, + `username` text NOT NULL, + `username_colour` text, + `email` text NOT NULL, + `email_verified` integer DEFAULT 0 NOT NULL, + `pronouns` text, + `verified` integer DEFAULT 0 NOT NULL, + `bio` text DEFAULT 'No bio set' NOT NULL, + `date_joined` text NOT NULL, + `plan` text DEFAULT 'free' NOT NULL, + `is_banned` integer DEFAULT false NOT NULL, + `is_contributor` integer DEFAULT false NOT NULL, + `role` text DEFAULT 'user' NOT NULL +); +--> statement-breakpoint +CREATE TABLE `stripeUser` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `stripe_customer_id` text, + `stripe_subscription_id` text, + `ends_at` text, + `paid_until` text, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `authSession` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `expires_at` text NOT NULL, + `user_agent` text NOT NULL, + `country_code` text NOT NULL, + `ip_address` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `emailVerificationToken` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token` text NOT NULL, + `expires_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `passwordResetToken` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token` text NOT NULL, + `expires_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userCollection` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `description` text NOT NULL, + `user_id` text NOT NULL, + `date_created` text NOT NULL, + `accent_colour` text, + `is_public` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userCollectionAsset` ( + `collection_id` text NOT NULL, + `asset_id` text NOT NULL, + `order` integer NOT NULL, + `date_added` text NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `userCollection`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `socialsConnection` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `discord_id` text, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userFollowing` ( + `followerId` text NOT NULL, + `followingId` text NOT NULL, + `createdAt` text NOT NULL, + FOREIGN KEY (`followerId`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`followingId`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userBlocked` ( + `id` text PRIMARY KEY NOT NULL, + `blocked_by_id` text NOT NULL, + `blocked_id` text NOT NULL, + FOREIGN KEY (`blocked_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`blocked_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userCollectionLikes` ( + `collection_id` text NOT NULL, + `liked_by_id` text NOT NULL, + `createdAt` text NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `userCollection`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`liked_by_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `userCollectionCollaborators` ( + `collection_id` text NOT NULL, + `collaborator_id` text NOT NULL, + `role` text DEFAULT 'collaborator' NOT NULL, + `createdAt` text NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `userCollection`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`collaborator_id`) REFERENCES `authUser`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `requestForm` ( + `id` text NOT NULL, + `user_id` text NOT NULL, + `type` text NOT NULL, + `title` text NOT NULL, + `description` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `requestFormUpvotes` ( + `id` text NOT NULL, + `request_form_id` text NOT NULL, + `user_id` text NOT NULL, + FOREIGN KEY (`request_form_id`) REFERENCES `requestForm`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `authUser`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `assets_id_idx` ON `asset` (`id`);--> statement-breakpoint +CREATE INDEX `assets_name_idx` ON `asset` (`name`);--> statement-breakpoint +CREATE INDEX `assets_game_name_idx` ON `asset` (`game`);--> statement-breakpoint +CREATE INDEX `assets_asset_category_name_idx` ON `asset` (`asset_category`);--> statement-breakpoint +CREATE INDEX `assets_uploaded_by_id_idx` ON `asset` (`uploaded_by_id`);--> statement-breakpoint +CREATE INDEX `assetcomments_asset_idx` ON `assetComments` (`asset_id`);--> statement-breakpoint +CREATE INDEX `assetcomments_parent_comment_idx` ON `assetComments` (`parent_comment_id`);--> statement-breakpoint +CREATE INDEX `assetcomments_commented_by_idx` ON `assetComments` (`commented_by_id`);--> statement-breakpoint +CREATE INDEX `assetcommentslikes_comment_idx` ON `assetCommentsLikes` (`comment_id`);--> statement-breakpoint +CREATE INDEX `assetcommentslikes_liked_by_idx` ON `assetCommentsLikes` (`liked_by_id`);--> statement-breakpoint +CREATE INDEX `asset_external_file_id_idx` ON `assetExternalFile` (`id`);--> statement-breakpoint +CREATE INDEX `asset_external_file_uploaded_by_id_idx` ON `assetExternalFile` (`uploaded_by`);--> statement-breakpoint +CREATE INDEX `asset_external_file_uploaded_by_name_idx` ON `assetExternalFile` (`uploaded_by_name`);--> statement-breakpoint +CREATE INDEX `assetlikes_asset_idx` ON `assetLikes` (`asset_id`);--> statement-breakpoint +CREATE INDEX `assetlikes_likedBy_idx` ON `assetLikes` (`liked_by_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `assetCategory_name_unique` ON `assetCategory` (`name`);--> statement-breakpoint +CREATE INDEX `asset_category_id_idx` ON `assetCategory` (`id`);--> statement-breakpoint +CREATE INDEX `asset_category_name_idx` ON `assetCategory` (`name`);--> statement-breakpoint +CREATE INDEX `game_asset_category_game_id_idx` ON `gameAssetCategory` (`game_id`);--> statement-breakpoint +CREATE INDEX `game_asset_category_asset_category_id_idx` ON `gameAssetCategory` (`asset_category_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `game_name_unique` ON `game` (`name`);--> statement-breakpoint +CREATE INDEX `game_id_idx` ON `game` (`id`);--> statement-breakpoint +CREATE INDEX `game_name_idx` ON `game` (`name`);--> statement-breakpoint +CREATE UNIQUE INDEX `assetTag_name_unique` ON `assetTag` (`name`);--> statement-breakpoint +CREATE INDEX `asset_tag_id_idx` ON `assetTag` (`id`);--> statement-breakpoint +CREATE INDEX `asset_tag_name_idx` ON `assetTag` (`name`);--> statement-breakpoint +CREATE INDEX `asset_tags_assets_asset_tag_id_idx` ON `assetTagAsset` (`asset_tag_id`);--> statement-breakpoint +CREATE INDEX `asset_tags_assets_asset_id_idx` ON `assetTagAsset` (`asset_id`);--> statement-breakpoint +CREATE INDEX `key_user_id_idx` ON `authCredentials` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `authUser_username_unique` ON `authUser` (`username`);--> statement-breakpoint +CREATE INDEX `user_id_idx` ON `authUser` (`id`);--> statement-breakpoint +CREATE INDEX `user_username_idx` ON `authUser` (`username`);--> statement-breakpoint +CREATE INDEX `user_email_idx` ON `authUser` (`email`);--> statement-breakpoint +CREATE INDEX `user_contributor_idx` ON `authUser` (`is_contributor`);--> statement-breakpoint +CREATE INDEX `stripe_user_user_id_idx` ON `stripeUser` (`user_id`);--> statement-breakpoint +CREATE INDEX `stripe_user_stripe_customer_id_idx` ON `stripeUser` (`stripe_customer_id`);--> statement-breakpoint +CREATE INDEX `session_user_id_idx` ON `authSession` (`user_id`);--> statement-breakpoint +CREATE INDEX `email_verification_token_user_id_idx` ON `emailVerificationToken` (`user_id`);--> statement-breakpoint +CREATE INDEX `email_verification_token_token_idx` ON `emailVerificationToken` (`token`);--> statement-breakpoint +CREATE INDEX `password_reset_token_user_id_idx` ON `passwordResetToken` (`user_id`);--> statement-breakpoint +CREATE INDEX `password_reset_token_token_idx` ON `passwordResetToken` (`token`);--> statement-breakpoint +CREATE INDEX `collection_id_idx` ON `userCollection` (`id`);--> statement-breakpoint +CREATE INDEX `user_collection_id_idx` ON `userCollection` (`user_id`);--> statement-breakpoint +CREATE INDEX `collection_assets_collection_id_idx` ON `userCollectionAsset` (`collection_id`);--> statement-breakpoint +CREATE INDEX `collection_assets_asset_id_idx` ON `userCollectionAsset` (`asset_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `socialsConnection_user_id_unique` ON `socialsConnection` (`user_id`);--> statement-breakpoint +CREATE INDEX `socials_connection_user_id_idx` ON `socialsConnection` (`user_id`);--> statement-breakpoint +CREATE INDEX `socials_connection_discord_id_idx` ON `socialsConnection` (`discord_id`);--> statement-breakpoint +CREATE INDEX `userfollowing_follower_idx` ON `userFollowing` (`followerId`);--> statement-breakpoint +CREATE INDEX `userfollowing_following_idx` ON `userFollowing` (`followingId`);--> statement-breakpoint +CREATE INDEX `user_blocked_id_idx` ON `userBlocked` (`id`);--> statement-breakpoint +CREATE INDEX `user_blocked_blocked_by_id_idx` ON `userBlocked` (`blocked_by_id`);--> statement-breakpoint +CREATE INDEX `user_blocked_blocked_id_idx` ON `userBlocked` (`blocked_id`);--> statement-breakpoint +CREATE INDEX `userCollectionNetworking_collection_idx` ON `userCollectionLikes` (`collection_id`);--> statement-breakpoint +CREATE INDEX `userCollectionNetworking_likedBy_idx` ON `userCollectionLikes` (`liked_by_id`);--> statement-breakpoint +CREATE INDEX `userCollectionCollaborators_collectionId_idx` ON `userCollectionCollaborators` (`collection_id`);--> statement-breakpoint +CREATE INDEX `userCollectionCollaborators_collaboratorId_idx` ON `userCollectionCollaborators` (`collaborator_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `requestForm_id_unique` ON `requestForm` (`id`);--> statement-breakpoint +CREATE INDEX `request_form_user_id_idx` ON `requestForm` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `requestFormUpvotes_id_unique` ON `requestFormUpvotes` (`id`);--> statement-breakpoint +CREATE INDEX `request_form_upvotes_idx` ON `requestFormUpvotes` (`request_form_id`,`user_id`); \ No newline at end of file diff --git a/src/v2/db/migrations/meta/0000_snapshot.json b/src/v2/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..26f1754 --- /dev/null +++ b/src/v2/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1855 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "e832f8e6-9b8a-46d2-9434-1c0ff8f8f453", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "asset": { + "name": "asset", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game": { + "name": "game", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_category": { + "name": "asset_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "uploaded_date": { + "name": "uploaded_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_is_suggestive": { + "name": "asset_is_suggestive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "comments_is_locked": { + "name": "comments_is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "assets_id_idx": { + "name": "assets_id_idx", + "columns": ["id"], + "isUnique": false + }, + "assets_name_idx": { + "name": "assets_name_idx", + "columns": ["name"], + "isUnique": false + }, + "assets_game_name_idx": { + "name": "assets_game_name_idx", + "columns": ["game"], + "isUnique": false + }, + "assets_asset_category_name_idx": { + "name": "assets_asset_category_name_idx", + "columns": ["asset_category"], + "isUnique": false + }, + "assets_uploaded_by_id_idx": { + "name": "assets_uploaded_by_id_idx", + "columns": ["uploaded_by_id"], + "isUnique": false + } + }, + "foreignKeys": { + "asset_game_game_id_fk": { + "name": "asset_game_game_id_fk", + "tableFrom": "asset", + "tableTo": "game", + "columnsFrom": ["game"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_asset_category_assetCategory_id_fk": { + "name": "asset_asset_category_assetCategory_id_fk", + "tableFrom": "asset", + "tableTo": "assetCategory", + "columnsFrom": ["asset_category"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_uploaded_by_id_authUser_id_fk": { + "name": "asset_uploaded_by_id_authUser_id_fk", + "tableFrom": "asset", + "tableTo": "authUser", + "columnsFrom": ["uploaded_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_uploaded_by_name_authUser_username_fk": { + "name": "asset_uploaded_by_name_authUser_username_fk", + "tableFrom": "asset", + "tableTo": "authUser", + "columnsFrom": ["uploaded_by_name"], + "columnsTo": ["username"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetComments": { + "name": "assetComments", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commented_by_id": { + "name": "commented_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": null + } + }, + "indexes": { + "assetcomments_asset_idx": { + "name": "assetcomments_asset_idx", + "columns": ["asset_id"], + "isUnique": false + }, + "assetcomments_parent_comment_idx": { + "name": "assetcomments_parent_comment_idx", + "columns": ["parent_comment_id"], + "isUnique": false + }, + "assetcomments_commented_by_idx": { + "name": "assetcomments_commented_by_idx", + "columns": ["commented_by_id"], + "isUnique": false + } + }, + "foreignKeys": { + "assetComments_asset_id_asset_id_fk": { + "name": "assetComments_asset_id_asset_id_fk", + "tableFrom": "assetComments", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetComments_commented_by_id_authUser_id_fk": { + "name": "assetComments_commented_by_id_authUser_id_fk", + "tableFrom": "assetComments", + "tableTo": "authUser", + "columnsFrom": ["commented_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "self_reference_parent_comment_id": { + "name": "self_reference_parent_comment_id", + "tableFrom": "assetComments", + "tableTo": "assetComments", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["comment_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetCommentsLikes": { + "name": "assetCommentsLikes", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "liked_by_id": { + "name": "liked_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assetcommentslikes_comment_idx": { + "name": "assetcommentslikes_comment_idx", + "columns": ["comment_id"], + "isUnique": false + }, + "assetcommentslikes_liked_by_idx": { + "name": "assetcommentslikes_liked_by_idx", + "columns": ["liked_by_id"], + "isUnique": false + } + }, + "foreignKeys": { + "assetCommentsLikes_comment_id_assetComments_comment_id_fk": { + "name": "assetCommentsLikes_comment_id_assetComments_comment_id_fk", + "tableFrom": "assetCommentsLikes", + "tableTo": "assetComments", + "columnsFrom": ["comment_id"], + "columnsTo": ["comment_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetCommentsLikes_liked_by_id_authUser_id_fk": { + "name": "assetCommentsLikes_liked_by_id_authUser_id_fk", + "tableFrom": "assetCommentsLikes", + "tableTo": "authUser", + "columnsFrom": ["liked_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetExternalFile": { + "name": "assetExternalFile", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_date": { + "name": "uploaded_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_external_file_id_idx": { + "name": "asset_external_file_id_idx", + "columns": ["id"], + "isUnique": false + }, + "asset_external_file_uploaded_by_id_idx": { + "name": "asset_external_file_uploaded_by_id_idx", + "columns": ["uploaded_by"], + "isUnique": false + }, + "asset_external_file_uploaded_by_name_idx": { + "name": "asset_external_file_uploaded_by_name_idx", + "columns": ["uploaded_by_name"], + "isUnique": false + } + }, + "foreignKeys": { + "assetExternalFile_uploaded_by_authUser_id_fk": { + "name": "assetExternalFile_uploaded_by_authUser_id_fk", + "tableFrom": "assetExternalFile", + "tableTo": "authUser", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetExternalFile_uploaded_by_name_authUser_username_fk": { + "name": "assetExternalFile_uploaded_by_name_authUser_username_fk", + "tableFrom": "assetExternalFile", + "tableTo": "authUser", + "columnsFrom": ["uploaded_by_name"], + "columnsTo": ["username"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetExternalFile_asset_id_asset_id_fk": { + "name": "assetExternalFile_asset_id_asset_id_fk", + "tableFrom": "assetExternalFile", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetLikes": { + "name": "assetLikes", + "columns": { + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "liked_by_id": { + "name": "liked_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assetlikes_asset_idx": { + "name": "assetlikes_asset_idx", + "columns": ["asset_id"], + "isUnique": false + }, + "assetlikes_likedBy_idx": { + "name": "assetlikes_likedBy_idx", + "columns": ["liked_by_id"], + "isUnique": false + } + }, + "foreignKeys": { + "assetLikes_asset_id_asset_id_fk": { + "name": "assetLikes_asset_id_asset_id_fk", + "tableFrom": "assetLikes", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetLikes_liked_by_id_authUser_id_fk": { + "name": "assetLikes_liked_by_id_authUser_id_fk", + "tableFrom": "assetLikes", + "tableTo": "authUser", + "columnsFrom": ["liked_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetCategory": { + "name": "assetCategory", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assetCategory_name_unique": { + "name": "assetCategory_name_unique", + "columns": ["name"], + "isUnique": true + }, + "asset_category_id_idx": { + "name": "asset_category_id_idx", + "columns": ["id"], + "isUnique": false + }, + "asset_category_name_idx": { + "name": "asset_category_name_idx", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "gameAssetCategory": { + "name": "gameAssetCategory", + "columns": { + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_category_id": { + "name": "asset_category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "game_asset_category_game_id_idx": { + "name": "game_asset_category_game_id_idx", + "columns": ["game_id"], + "isUnique": false + }, + "game_asset_category_asset_category_id_idx": { + "name": "game_asset_category_asset_category_id_idx", + "columns": ["asset_category_id"], + "isUnique": false + } + }, + "foreignKeys": { + "gameAssetCategory_game_id_game_id_fk": { + "name": "gameAssetCategory_game_id_game_id_fk", + "tableFrom": "gameAssetCategory", + "tableTo": "game", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "gameAssetCategory_asset_category_id_assetCategory_id_fk": { + "name": "gameAssetCategory_asset_category_id_assetCategory_id_fk", + "tableFrom": "gameAssetCategory", + "tableTo": "assetCategory", + "columnsFrom": ["asset_category_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "game": { + "name": "game", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "possible_suggestive_content": { + "name": "possible_suggestive_content", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "game_name_unique": { + "name": "game_name_unique", + "columns": ["name"], + "isUnique": true + }, + "game_id_idx": { + "name": "game_id_idx", + "columns": ["id"], + "isUnique": false + }, + "game_name_idx": { + "name": "game_name_idx", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetTag": { + "name": "assetTag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assetTag_name_unique": { + "name": "assetTag_name_unique", + "columns": ["name"], + "isUnique": true + }, + "asset_tag_id_idx": { + "name": "asset_tag_id_idx", + "columns": ["id"], + "isUnique": false + }, + "asset_tag_name_idx": { + "name": "asset_tag_name_idx", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assetTagAsset": { + "name": "assetTagAsset", + "columns": { + "asset_tag_id": { + "name": "asset_tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_tags_assets_asset_tag_id_idx": { + "name": "asset_tags_assets_asset_tag_id_idx", + "columns": ["asset_tag_id"], + "isUnique": false + }, + "asset_tags_assets_asset_id_idx": { + "name": "asset_tags_assets_asset_id_idx", + "columns": ["asset_id"], + "isUnique": false + } + }, + "foreignKeys": { + "assetTagAsset_asset_tag_id_assetTag_id_fk": { + "name": "assetTagAsset_asset_tag_id_assetTag_id_fk", + "tableFrom": "assetTagAsset", + "tableTo": "assetTag", + "columnsFrom": ["asset_tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assetTagAsset_asset_id_asset_id_fk": { + "name": "assetTagAsset_asset_id_asset_id_fk", + "tableFrom": "assetTagAsset", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "authCredentials": { + "name": "authCredentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "key_user_id_idx": { + "name": "key_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "authCredentials_user_id_authUser_id_fk": { + "name": "authCredentials_user_id_authUser_id_fk", + "tableFrom": "authCredentials", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "authUser": { + "name": "authUser", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username_colour": { + "name": "username_colour", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified": { + "name": "verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'No bio set'" + }, + "date_joined": { + "name": "date_joined", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "is_banned": { + "name": "is_banned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_contributor": { + "name": "is_contributor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "authUser_username_unique": { + "name": "authUser_username_unique", + "columns": ["username"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["id"], + "isUnique": false + }, + "user_username_idx": { + "name": "user_username_idx", + "columns": ["username"], + "isUnique": false + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": ["email"], + "isUnique": false + }, + "user_contributor_idx": { + "name": "user_contributor_idx", + "columns": ["is_contributor"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "stripeUser": { + "name": "stripeUser", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "stripe_user_user_id_idx": { + "name": "stripe_user_user_id_idx", + "columns": ["user_id"], + "isUnique": false + }, + "stripe_user_stripe_customer_id_idx": { + "name": "stripe_user_stripe_customer_id_idx", + "columns": ["stripe_customer_id"], + "isUnique": false + } + }, + "foreignKeys": { + "stripeUser_user_id_authUser_id_fk": { + "name": "stripeUser_user_id_authUser_id_fk", + "tableFrom": "stripeUser", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "authSession": { + "name": "authSession", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "authSession_user_id_authUser_id_fk": { + "name": "authSession_user_id_authUser_id_fk", + "tableFrom": "authSession", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "emailVerificationToken": { + "name": "emailVerificationToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_verification_token_user_id_idx": { + "name": "email_verification_token_user_id_idx", + "columns": ["user_id"], + "isUnique": false + }, + "email_verification_token_token_idx": { + "name": "email_verification_token_token_idx", + "columns": ["token"], + "isUnique": false + } + }, + "foreignKeys": { + "emailVerificationToken_user_id_authUser_id_fk": { + "name": "emailVerificationToken_user_id_authUser_id_fk", + "tableFrom": "emailVerificationToken", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "password_reset_token_user_id_idx": { + "name": "password_reset_token_user_id_idx", + "columns": ["user_id"], + "isUnique": false + }, + "password_reset_token_token_idx": { + "name": "password_reset_token_token_idx", + "columns": ["token"], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_user_id_authUser_id_fk": { + "name": "passwordResetToken_user_id_authUser_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCollection": { + "name": "userCollection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_created": { + "name": "date_created", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accent_colour": { + "name": "accent_colour", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "collection_id_idx": { + "name": "collection_id_idx", + "columns": ["id"], + "isUnique": false + }, + "user_collection_id_idx": { + "name": "user_collection_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "userCollection_user_id_authUser_id_fk": { + "name": "userCollection_user_id_authUser_id_fk", + "tableFrom": "userCollection", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCollectionAsset": { + "name": "userCollectionAsset", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_added": { + "name": "date_added", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_assets_collection_id_idx": { + "name": "collection_assets_collection_id_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "collection_assets_asset_id_idx": { + "name": "collection_assets_asset_id_idx", + "columns": ["asset_id"], + "isUnique": false + } + }, + "foreignKeys": { + "userCollectionAsset_collection_id_userCollection_id_fk": { + "name": "userCollectionAsset_collection_id_userCollection_id_fk", + "tableFrom": "userCollectionAsset", + "tableTo": "userCollection", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "userCollectionAsset_asset_id_asset_id_fk": { + "name": "userCollectionAsset_asset_id_asset_id_fk", + "tableFrom": "userCollectionAsset", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "socialsConnection": { + "name": "socialsConnection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "socialsConnection_user_id_unique": { + "name": "socialsConnection_user_id_unique", + "columns": ["user_id"], + "isUnique": true + }, + "socials_connection_user_id_idx": { + "name": "socials_connection_user_id_idx", + "columns": ["user_id"], + "isUnique": false + }, + "socials_connection_discord_id_idx": { + "name": "socials_connection_discord_id_idx", + "columns": ["discord_id"], + "isUnique": false + } + }, + "foreignKeys": { + "socialsConnection_user_id_authUser_id_fk": { + "name": "socialsConnection_user_id_authUser_id_fk", + "tableFrom": "socialsConnection", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userFollowing": { + "name": "userFollowing", + "columns": { + "followerId": { + "name": "followerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "followingId": { + "name": "followingId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "userfollowing_follower_idx": { + "name": "userfollowing_follower_idx", + "columns": ["followerId"], + "isUnique": false + }, + "userfollowing_following_idx": { + "name": "userfollowing_following_idx", + "columns": ["followingId"], + "isUnique": false + } + }, + "foreignKeys": { + "userFollowing_followerId_authUser_id_fk": { + "name": "userFollowing_followerId_authUser_id_fk", + "tableFrom": "userFollowing", + "tableTo": "authUser", + "columnsFrom": ["followerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "userFollowing_followingId_authUser_id_fk": { + "name": "userFollowing_followingId_authUser_id_fk", + "tableFrom": "userFollowing", + "tableTo": "authUser", + "columnsFrom": ["followingId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userBlocked": { + "name": "userBlocked", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocked_by_id": { + "name": "blocked_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blocked_id": { + "name": "blocked_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_blocked_id_idx": { + "name": "user_blocked_id_idx", + "columns": ["id"], + "isUnique": false + }, + "user_blocked_blocked_by_id_idx": { + "name": "user_blocked_blocked_by_id_idx", + "columns": ["blocked_by_id"], + "isUnique": false + }, + "user_blocked_blocked_id_idx": { + "name": "user_blocked_blocked_id_idx", + "columns": ["blocked_id"], + "isUnique": false + } + }, + "foreignKeys": { + "userBlocked_blocked_by_id_authUser_id_fk": { + "name": "userBlocked_blocked_by_id_authUser_id_fk", + "tableFrom": "userBlocked", + "tableTo": "authUser", + "columnsFrom": ["blocked_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "userBlocked_blocked_id_authUser_id_fk": { + "name": "userBlocked_blocked_id_authUser_id_fk", + "tableFrom": "userBlocked", + "tableTo": "authUser", + "columnsFrom": ["blocked_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCollectionLikes": { + "name": "userCollectionLikes", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "liked_by_id": { + "name": "liked_by_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "userCollectionNetworking_collection_idx": { + "name": "userCollectionNetworking_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "userCollectionNetworking_likedBy_idx": { + "name": "userCollectionNetworking_likedBy_idx", + "columns": ["liked_by_id"], + "isUnique": false + } + }, + "foreignKeys": { + "userCollectionLikes_collection_id_userCollection_id_fk": { + "name": "userCollectionLikes_collection_id_userCollection_id_fk", + "tableFrom": "userCollectionLikes", + "tableTo": "userCollection", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "userCollectionLikes_liked_by_id_authUser_id_fk": { + "name": "userCollectionLikes_liked_by_id_authUser_id_fk", + "tableFrom": "userCollectionLikes", + "tableTo": "authUser", + "columnsFrom": ["liked_by_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCollectionCollaborators": { + "name": "userCollectionCollaborators", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'collaborator'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "userCollectionCollaborators_collectionId_idx": { + "name": "userCollectionCollaborators_collectionId_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "userCollectionCollaborators_collaboratorId_idx": { + "name": "userCollectionCollaborators_collaboratorId_idx", + "columns": ["collaborator_id"], + "isUnique": false + } + }, + "foreignKeys": { + "userCollectionCollaborators_collection_id_userCollection_id_fk": { + "name": "userCollectionCollaborators_collection_id_userCollection_id_fk", + "tableFrom": "userCollectionCollaborators", + "tableTo": "userCollection", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "userCollectionCollaborators_collaborator_id_authUser_id_fk": { + "name": "userCollectionCollaborators_collaborator_id_authUser_id_fk", + "tableFrom": "userCollectionCollaborators", + "tableTo": "authUser", + "columnsFrom": ["collaborator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "requestForm": { + "name": "requestForm", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "requestForm_id_unique": { + "name": "requestForm_id_unique", + "columns": ["id"], + "isUnique": true + }, + "request_form_user_id_idx": { + "name": "request_form_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "requestForm_user_id_authUser_id_fk": { + "name": "requestForm_user_id_authUser_id_fk", + "tableFrom": "requestForm", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "requestFormUpvotes": { + "name": "requestFormUpvotes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_form_id": { + "name": "request_form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "requestFormUpvotes_id_unique": { + "name": "requestFormUpvotes_id_unique", + "columns": ["id"], + "isUnique": true + }, + "request_form_upvotes_idx": { + "name": "request_form_upvotes_idx", + "columns": ["request_form_id", "user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "requestFormUpvotes_request_form_id_requestForm_id_fk": { + "name": "requestFormUpvotes_request_form_id_requestForm_id_fk", + "tableFrom": "requestFormUpvotes", + "tableTo": "requestForm", + "columnsFrom": ["request_form_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "requestFormUpvotes_user_id_authUser_id_fk": { + "name": "requestFormUpvotes_user_id_authUser_id_fk", + "tableFrom": "requestFormUpvotes", + "tableTo": "authUser", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/src/v2/db/migrations/meta/_journal.json b/src/v2/db/migrations/meta/_journal.json new file mode 100644 index 0000000..eb5f8ff --- /dev/null +++ b/src/v2/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1712221494215, + "tag": "0000_lazy_krista_starr", + "breakpoints": true + } + ] +} diff --git a/src/v2/db/schema.ts b/src/v2/db/schema.ts new file mode 100644 index 0000000..18bdb84 --- /dev/null +++ b/src/v2/db/schema.ts @@ -0,0 +1,25 @@ +export * from "./schema/asset/asset" +export * from "./schema/asset/asset-comments" +export * from "./schema/asset/asset-external-files" +export * from "./schema/asset/asset-likes" + +export * from "./schema/categories/asset-categories" +// export * from "./schema/categories/asset-categories-likes" + +export * from "./schema/game/game" +// export * from "./schema/game/game-likes" + +export * from "./schema/tags/asset-tags" +// export * from "./schema/tags/asset-tags-likes" + +export * from "./schema/user/user" +export * from "./schema/user/user-attributes" +export * from "./schema/collections/user-collections" +export * from "./schema/user/user-connections" +export * from "./schema/user/user-following" +export * from "./schema/user/user-blocked" + +export * from "./schema/collections/user-collection-likes" +export * from "./schema/collections/user-collections-collaborators" + +export * from "./schema/supporter/request-form" diff --git a/src/v2/db/schema/asset/asset-comments.ts b/src/v2/db/schema/asset/asset-comments.ts new file mode 100644 index 0000000..86f0e42 --- /dev/null +++ b/src/v2/db/schema/asset/asset-comments.ts @@ -0,0 +1,143 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, + foreignKey, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { asset } from "./asset" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { generateID } from "@/v2/lib/oslo" + +export const assetComments = sqliteTable( + tableNames.assetComments, + { + id: text("comment_id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID(20) + }), + assetId: text("asset_id").references(() => asset.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + // typescript limitations means that the type will be set as `any` if we self reference, so we create FK manually + parentCommentId: text("parent_comment_id"), + commentedById: text("commented_by_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + comment: text("comment").notNull(), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + editedAt: text("edited_at").default(null), + }, + (assetComments) => { + return { + parentCommentFk: foreignKey({ + name: "self_reference_parent_comment_id", + columns: [assetComments.parentCommentId], + foreignColumns: [assetComments.id], + }), + assetIdx: index("assetcomments_asset_idx").on( + assetComments.assetId + ), + parentCommentIdx: index("assetcomments_parent_comment_idx").on( + assetComments.parentCommentId + ), + commentedByIdx: index("assetcomments_commented_by_idx").on( + assetComments.commentedById + ), + } + } +) + +export type AssetComments = typeof assetComments.$inferSelect +export type NewAssetComments = typeof assetComments.$inferInsert + +export const insertAssetCommentsSchema = createInsertSchema(assetComments) +export const selectAssetCommentsSchema = createSelectSchema(assetComments) + +export const assetCommentsLikes = sqliteTable( + tableNames.assetCommentsLikes, + { + commentId: text("comment_id") + .notNull() + .references(() => assetComments.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + likedById: text("liked_by_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (assetCommentsLikes) => { + return { + commentIdx: index("assetcommentslikes_comment_idx").on( + assetCommentsLikes.commentId + ), + likedByIdx: index("assetcommentslikes_liked_by_idx").on( + assetCommentsLikes.likedById + ), + } + } +) + +export type AssetCommentsLikes = typeof assetCommentsLikes.$inferSelect +export type NewAssetCommentsLikes = typeof assetCommentsLikes.$inferInsert + +export const insertAssetCommentsLikesSchema = + createInsertSchema(assetCommentsLikes) +export const selectAssetCommentsLikesSchema = + createSelectSchema(assetCommentsLikes) + +// not too sure about this +export const assetCommentsRelations = relations( + assetComments, + ({ one, many }) => ({ + asset: one(asset, { + fields: [assetComments.assetId], + references: [asset.id], + relationName: "asset_comments_asset", + }), + commentedBy: one(authUser, { + fields: [assetComments.commentedById], + references: [authUser.id], + relationName: "asset_comments_commented_by", + }), + assetCommentsLikes: many(assetCommentsLikes), + }) +) + +export const assetCommentsLikesRelations = relations( + assetCommentsLikes, + ({ one }) => ({ + comment: one(assetComments, { + fields: [assetCommentsLikes.commentId], + references: [assetComments.id], + relationName: "asset_comments_likes_comment", + }), + likedBy: one(authUser, { + fields: [assetCommentsLikes.likedById], + references: [authUser.id], + relationName: "asset_comments_likes_liked_by", + }), + }) +) diff --git a/src/v2/db/schema/asset/asset-external-files.ts b/src/v2/db/schema/asset/asset-external-files.ts new file mode 100644 index 0000000..8a9ba7d --- /dev/null +++ b/src/v2/db/schema/asset/asset-external-files.ts @@ -0,0 +1,83 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + integer, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { asset } from "./asset" +import { authUser } from "../user/user" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: this allows for users down the line to link files to assets +*/ + +type AllowedFileTypes = "atlas" | "skel" | "png" | "jpg" | "jpeg" // this is just temporary + +export const assetExternalFile = sqliteTable( + tableNames.assetExternalFile, + { + id: text("id").primaryKey().notNull(), + url: text("url").notNull(), + uploadedById: text("uploaded_by") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + uploadedByName: text("uploaded_by_name") + .notNull() + .references(() => authUser.username, { + onUpdate: "cascade", + onDelete: "cascade", + }), + assetId: text("asset_id") + .notNull() + .references(() => asset.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + uploadedDate: text("uploaded_date") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + fileSize: integer("file_size").default(0).notNull(), + fileType: text("file_type").notNull().$type(), + }, + (table) => { + return { + idIdx: index("asset_external_file_id_idx").on(table.id), + uploadedByIdIdx: index("asset_external_file_uploaded_by_id_idx").on( + table.uploadedById + ), + uploadedByNameIdx: index( + "asset_external_file_uploaded_by_name_idx" + ).on(table.uploadedByName), + } + } +) + +export type AssetExtrnalFile = typeof assetExternalFile.$inferSelect +export type NewAssetExtrnalFile = typeof assetExternalFile.$inferInsert +export const insertExtrnalFileSchema = createInsertSchema(assetExternalFile) +export const selectExternalFileSchema = createSelectSchema(assetExternalFile) + +export const assetExternalFileRelations = relations( + assetExternalFile, + ({ one }) => ({ + asset: one(asset, { + fields: [assetExternalFile.assetId], + references: [asset.id], + relationName: "asset_external_file_asset", + }), + uploadedBy: one(authUser, { + fields: [assetExternalFile.uploadedById], + references: [authUser.id], + relationName: "asset_external_file_auth_user", + }), + }) +) diff --git a/src/v2/db/schema/asset/asset-likes.ts b/src/v2/db/schema/asset/asset-likes.ts new file mode 100644 index 0000000..e827a81 --- /dev/null +++ b/src/v2/db/schema/asset/asset-likes.ts @@ -0,0 +1,60 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { asset } from "./asset" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +export const assetLikes = sqliteTable( + tableNames.assetLikes, + { + assetId: text("asset_id") + .notNull() + .references(() => asset.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + likedById: text("liked_by_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (assetLikes) => { + return { + assetIdx: index("assetlikes_asset_idx").on(assetLikes.assetId), + likedByIdx: index("assetlikes_likedBy_idx").on( + assetLikes.likedById + ), + } + } +) + +export type AssetLikes = typeof assetLikes.$inferSelect +export type NewAssetLikes = typeof assetLikes.$inferInsert +export const insertAssetLikesSchema = createInsertSchema(assetLikes) +export const selectAssetLikesSchema = createSelectSchema(assetLikes) + +export const assetLikesRelations = relations(assetLikes, ({ one }) => ({ + asset: one(asset, { + fields: [assetLikes.assetId], + references: [asset.id], + relationName: "assetlikes_liked_asset", + }), + likedBy: one(authUser, { + fields: [assetLikes.likedById], + references: [authUser.id], + relationName: "assetlikes_liked_by", + }), +})) diff --git a/src/v2/db/schema/asset/asset.ts b/src/v2/db/schema/asset/asset.ts new file mode 100644 index 0000000..aad3330 --- /dev/null +++ b/src/v2/db/schema/asset/asset.ts @@ -0,0 +1,129 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + integer, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { assetCategory } from "../categories/asset-categories" +import { game } from "../game/game" +import { assetTagAsset } from "../tags/asset-tags" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { assetLikes } from "./asset-likes" +import { assetExternalFile } from "./asset-external-files" +import { assetComments } from "./asset-comments" +import { generateID } from "@/v2/lib/oslo" + +/* +NOTE: Assets have a lot of relations, and can be quite complex in some cases. +- UploadedBy: Linked to the user who uploaded the asset. +- AssetTagAsset: Linked to the tags the asset has, as an asset can have multiple tags, e.g "official", "1.0" +- AssetCategory: Linked to the category the asset is in, e.g "charcter sheets" +- Game: Linked to the game the asset is for, e.g "genshin-impact" + +Then, they are also used as relations when adding to collections or favourites. +*/ + +export type AssetStatus = "pending" | "approved" | "rejected" + +export const asset = sqliteTable( + tableNames.asset, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID(15) + }), + name: text("name").notNull(), + extension: text("extension").notNull(), + gameId: text("game") + .references(() => game.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + assetCategoryId: text("asset_category") + .references(() => assetCategory.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + uploadedById: text("uploaded_by_id") + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + uploadedByName: text("uploaded_by_name") + .references(() => authUser.username, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + url: text("url").notNull(), + status: text("status") + .$type() + .default("pending") + .notNull(), + uploadedDate: text("uploaded_date") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + assetIsSuggestive: integer("asset_is_suggestive", { mode: "boolean" }) + .default(false) + .notNull(), + allowComments: integer("comments_is_locked", { mode: "boolean" }) + .default(false) + .notNull(), + viewCount: integer("view_count").default(0).notNull(), + downloadCount: integer("download_count").default(0).notNull(), + fileSize: integer("file_size").default(0).notNull(), + width: integer("width").default(0).notNull(), + height: integer("height").default(0).notNull(), + }, + (table) => { + return { + idIdx: index("assets_id_idx").on(table.id), + nameIdx: index("assets_name_idx").on(table.name), + gameIdIdx: index("assets_game_name_idx").on(table.gameId), + assetCategoryIdIdx: index("assets_asset_category_name_idx").on( + table.assetCategoryId + ), + uploadedByIdIdx: index("assets_uploaded_by_id_idx").on( + table.uploadedById + ), + } + } +) + +export type Asset = typeof asset.$inferSelect +export type NewAsset = typeof asset.$inferInsert +export const insertAssetSchema = createInsertSchema(asset) +export const selectAssetSchema = createSelectSchema(asset) + +export const assetRelations = relations(asset, ({ one, many }) => ({ + assetTagAsset: many(assetTagAsset), + assetExternalFile: many(assetExternalFile), + assetLikes: many(assetLikes), + assetComments: many(assetComments), + authUser: one(authUser, { + fields: [asset.uploadedById, asset.uploadedByName], + references: [authUser.id, authUser.username], + relationName: "asset_auth_user", + }), + game: one(game, { + fields: [asset.gameId], + references: [game.id], + relationName: "asset_game", + }), + assetCategory: one(assetCategory, { + fields: [asset.assetCategoryId], + references: [assetCategory.id], + relationName: "asset_asset_category", + }), +})) diff --git a/src/v2/db/schema/categories/asset-categories-likes.ts b/src/v2/db/schema/categories/asset-categories-likes.ts new file mode 100644 index 0000000..59eaab8 --- /dev/null +++ b/src/v2/db/schema/categories/asset-categories-likes.ts @@ -0,0 +1,62 @@ +// import { tableNames } from "@/v2/db/drizzle" +// import { relations } from "drizzle-orm" +// import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +// import { authUser } from "../user/user" +// import { createInsertSchema, createSelectSchema } from "drizzle-zod" +// import { assetCategory } from "./asset-categories" + +// export const assetCategoryLikes = sqliteTable( +// tableNames.assetCategoryLikes, +// { +// assetCategoryId: text("asset_id") +// .notNull() +// .references(() => assetCategory.id, { +// onUpdate: "cascade", +// onDelete: "cascade", +// }), +// likedById: text("liked_by_id") +// .notNull() +// .references(() => authUser.id, { +// onUpdate: "cascade", +// onDelete: "cascade", +// }), +// createdAt: text("created_at") +// .notNull() +// .$defaultFn(() => { +// return new Date().toISOString() +// }), +// }, +// (gameLikes) => { +// return { +// assetCategoryIdx: index("assetCategoryLikes_asset_idx").on( +// gameLikes.assetCategoryId +// ), +// likedByIdx: index("assetCategoryLikes_likedby_idx").on( +// gameLikes.likedById +// ), +// } +// } +// ) + +// export type AssetCategoryLikes = typeof assetCategoryLikes.$inferSelect +// export type NewAssetCategoryLikes = typeof assetCategoryLikes.$inferInsert +// export const insertAssetCategoryLikesSchema = +// createInsertSchema(assetCategoryLikes) +// export const selectAssetCategoryLikesSchema = +// createSelectSchema(assetCategoryLikes) + +// export const assetCategoryLikesRelations = relations( +// assetCategoryLikes, +// ({ one }) => ({ +// assetCategory: one(assetCategory, { +// fields: [assetCategoryLikes.assetCategoryId], +// references: [assetCategory.id], +// relationName: "assetCategoryLikes_liked_assetCategory", +// }), +// likedBy: one(authUser, { +// fields: [assetCategoryLikes.likedById], +// references: [authUser.id], +// relationName: "assetTagLikes_liked_by", +// }), +// }) +// ) diff --git a/src/v2/db/schema/categories/asset-categories.ts b/src/v2/db/schema/categories/asset-categories.ts new file mode 100644 index 0000000..5885244 --- /dev/null +++ b/src/v2/db/schema/categories/asset-categories.ts @@ -0,0 +1,98 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { game } from "../game/game" +import { asset } from "../asset/asset" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +// import { assetCategoryLikes } from "./asset-categories-likes" + +export const assetCategory = sqliteTable( + tableNames.assetCategory, + { + id: text("id").primaryKey().notNull(), // e.g tcg-sheets, splash-art + name: text("name").unique().notNull(), // e.g tcg-sheets, splash-art + formattedName: text("formatted_name").notNull(), // e.g TCG Sheets, Splash Art + lastUpdated: text("last_updated").notNull(), + }, + (assetCategory) => { + return { + assetCategoryIdx: index("asset_category_id_idx").on( + assetCategory.id + ), + nameIdx: index("asset_category_name_idx").on(assetCategory.name), + } + } +) + +export type AssetCategory = typeof assetCategory.$inferSelect +export type NewAssetCategory = typeof assetCategory.$inferInsert +export const insertAssetCategorySchema = createInsertSchema(assetCategory) +export const selectAssetCategorySchema = createSelectSchema(assetCategory) + +/* +NOTE: This setup can look kinda janky. +- All asset categories have a game associated with them. This is for better UX so users know what asset categories exist for a game. +- It's not a necessary join, but just nice to have. +*/ + +export const gameAssetCategory = sqliteTable( + tableNames.gameAssetCategory, + { + gameId: text("game_id") + .notNull() + .references(() => game.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + assetCategoryId: text("asset_category_id") + .notNull() + .references(() => assetCategory.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + }, + (gameAssetCategory) => { + return { + gameAssetCategoryGameIdx: index( + "game_asset_category_game_id_idx" + ).on(gameAssetCategory.gameId), + gameAssetCategoryAssetCategoryIdx: index( + "game_asset_category_asset_category_id_idx" + ).on(gameAssetCategory.assetCategoryId), + } + } +) + +export type GameAssetCategory = typeof gameAssetCategory.$inferSelect +export type NewGameAssetCategory = typeof gameAssetCategory.$inferInsert +export const insertGameAssetCategorySchema = + createInsertSchema(gameAssetCategory) +export const selectGameAssetCategorySchema = + createSelectSchema(gameAssetCategory) + +export const assetCategoryRelations = relations(assetCategory, ({ many }) => ({ + asset: many(asset), + gameAssetCategory: many(gameAssetCategory), + // assetCategoryLikes: many(assetCategoryLikes), +})) + +export const gameAssetCategoryRelations = relations( + gameAssetCategory, + ({ one }) => ({ + game: one(game, { + fields: [gameAssetCategory.gameId], + references: [game.id], + relationName: "gameassetcategory_game", + }), + assetCategory: one(assetCategory, { + fields: [gameAssetCategory.assetCategoryId], + references: [assetCategory.id], + relationName: "gameassetcategory_assetcategory", + }), + }) +) diff --git a/src/v2/db/schema/collections/user-collection-likes.ts b/src/v2/db/schema/collections/user-collection-likes.ts new file mode 100644 index 0000000..bc9518b --- /dev/null +++ b/src/v2/db/schema/collections/user-collection-likes.ts @@ -0,0 +1,67 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { userCollection } from "./user-collections" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +export const userCollectionLikes = sqliteTable( + tableNames.userCollectionLikes, + { + collectionId: text("collection_id") + .notNull() + .references(() => userCollection.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + likedById: text("liked_by_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (userCollectionLikes) => { + return { + collectionIdx: index("userCollectionNetworking_collection_idx").on( + userCollectionLikes.collectionId + ), + likedByIdx: index("userCollectionNetworking_likedBy_idx").on( + userCollectionLikes.likedById + ), + } + } +) + +export type UserCollectionLikes = typeof userCollectionLikes.$inferSelect +export type NewUserCollectionLikes = typeof userCollectionLikes.$inferInsert +export const insertUserCollectionLikesSchema = + createInsertSchema(userCollectionLikes) +export const selectUserCollectionLikesSchema = + createSelectSchema(userCollectionLikes) + +export const userCollectionLikesRelations = relations( + userCollectionLikes, + ({ one }) => ({ + collection: one(userCollection, { + fields: [userCollectionLikes.collectionId], + references: [userCollection.id], + relationName: "usercollectionlikes_liked_collection", + }), + likedBy: one(authUser, { + fields: [userCollectionLikes.likedById], + references: [authUser.id], + relationName: "usercollectionlikes_liked_by", + }), + }) +) diff --git a/src/v2/db/schema/collections/user-collections-collaborators.ts b/src/v2/db/schema/collections/user-collections-collaborators.ts new file mode 100644 index 0000000..4313971 --- /dev/null +++ b/src/v2/db/schema/collections/user-collections-collaborators.ts @@ -0,0 +1,74 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { userCollection } from "./user-collections" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +// editor: maximum permissions, can edit collection name/description +// collaborator: can add/remove assets from collection +// viewer: can view collection depending on privacy settings +export type CollaboratorsRoles = "viewer" | "collaborator" | "editor" + +export const userCollectionCollaborators = sqliteTable( + tableNames.userCollectionCollaborators, + { + collectionId: text("collection_id") + .notNull() + .references(() => userCollection.id), + collaboratorId: text("collaborator_id") + .notNull() + .references(() => authUser.id), + role: text("role") + .$type() + .default("collaborator") + .notNull(), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (userCollectionCollaborators) => { + return { + collectionIdx: index( + "userCollectionCollaborators_collectionId_idx" + ).on(userCollectionCollaborators.collectionId), + collaboratorId: index( + "userCollectionCollaborators_collaboratorId_idx" + ).on(userCollectionCollaborators.collaboratorId), + } + } +) + +export type UserCollectionCollaborators = + typeof userCollectionCollaborators.$inferSelect +export type NewUserCollectionCollaborators = + typeof userCollectionCollaborators.$inferInsert +export const insertUserCollectionCollaboratorsSchema = createInsertSchema( + userCollectionCollaborators +) +export const selectUserCollectionCollaboratorsSchema = createSelectSchema( + userCollectionCollaborators +) + +export const userCollectionCollaboratorsRelations = relations( + userCollectionCollaborators, + ({ one }) => ({ + collection: one(userCollection, { + fields: [userCollectionCollaborators.collectionId], + references: [userCollection.id], + relationName: "userCollectionCollaborators_collection_id", + }), + collaborator: one(authUser, { + fields: [userCollectionCollaborators.collaboratorId], + references: [authUser.id], + relationName: "userCollectionCollaborators_collaborator_id", + }), + }) +) diff --git a/src/v2/db/schema/collections/user-collections.ts b/src/v2/db/schema/collections/user-collections.ts new file mode 100644 index 0000000..9c5dda6 --- /dev/null +++ b/src/v2/db/schema/collections/user-collections.ts @@ -0,0 +1,147 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + integer, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "../user/user" +import { asset } from "../asset/asset" +import { generateID } from "@/v2/lib/oslo" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { userCollectionLikes } from "./user-collection-likes" +import { userCollectionCollaborators } from "./user-collections-collaborators" +import type { ColourType } from "@/v2/lib/colour" + +/* +NOTE: this file is where users store their collections of assets. +- UserCollection is the collection itself, which has a name, description, and whether it's public or not. +- UserCollectionAsset is the join table between UserCollection and Asset, which stores the assets in the collection. +*/ + +export const userCollection = sqliteTable( + tableNames.userCollection, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID() + }), + // parentCollectionId: text("parent_collection_id").references( + // () => userCollection.id, + // { + // onUpdate: "cascade", + // onDelete: "cascade", + // } + // ), + name: text("name").notNull(), + description: text("description").notNull(), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + dateCreated: text("date_created") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + accentColour: text("accent_colour").$type(), + isPublic: integer("is_public", { mode: "boolean" }) + .default(false) + .notNull(), + }, + (collection) => { + return { + collectionIdx: index("collection_id_idx").on(collection.id), + userCollectionIdx: index("user_collection_id_idx").on( + collection.userId + ), + } + } +) + +export type UserCollection = typeof userCollection.$inferSelect +export type NewUserCollection = typeof userCollection.$inferInsert +export const insertUserCollectionSchema = createInsertSchema(userCollection) +export const selectUserCollectionSchema = createSelectSchema(userCollection) + +export const userCollectionAsset = sqliteTable( + tableNames.userCollectionAsset, + { + collectionId: text("collection_id") + .notNull() + .references(() => userCollection.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + assetId: text("asset_id") + .notNull() + .references(() => asset.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + order: integer("order").notNull(), + dateAdded: text("date_added") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (collectionAssets) => { + return { + collectionAssetsCollectionIdx: index( + "collection_assets_collection_id_idx" + ).on(collectionAssets.collectionId), + collectionAssetsAssetIdx: index( + "collection_assets_asset_id_idx" + ).on(collectionAssets.assetId), + } + } +) + +export type UserCollectionAsset = typeof userCollectionAsset.$inferSelect +export type NewUserCollectionAsset = typeof userCollectionAsset.$inferInsert +export const insertUserCollectionAssetSchema = + createInsertSchema(userCollectionAsset) +export const selectUserCollectionAssetSchema = + createSelectSchema(userCollectionAsset) + +export const collectionRelations = relations( + userCollection, + ({ one, many }) => ({ + authUser: one(authUser, { + fields: [userCollection.userId], + references: [authUser.id], + relationName: "collection_auth_user", + }), + assets: many(userCollectionAsset), + userCollectionLikes: many(userCollectionLikes), + userCollectionCollaborators: many(userCollectionCollaborators), + // parentCollection: one(userCollection, { + // fields: [userCollection.parentCollectionId], + // references: [userCollection.id], + // relationName: "collection_parent_collection", + // }), + }) +) + +export const collectionAssetsRelations = relations( + userCollectionAsset, + ({ one }) => ({ + collection: one(userCollection, { + fields: [userCollectionAsset.collectionId], + references: [userCollection.id], + relationName: "collectionassets_collection", + }), + asset: one(asset, { + fields: [userCollectionAsset.assetId], + references: [asset.id], + relationName: "collectionassets_asset", + }), + }) +) diff --git a/src/v2/db/schema/game/game-likes.ts b/src/v2/db/schema/game/game-likes.ts new file mode 100644 index 0000000..9eda813 --- /dev/null +++ b/src/v2/db/schema/game/game-likes.ts @@ -0,0 +1,53 @@ +// import { tableNames } from "@/v2/db/drizzle" +// import { relations } from "drizzle-orm" +// import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +// import { authUser } from "../user/user" +// import { game } from "./game" +// import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +// export const gameLikes = sqliteTable( +// tableNames.gameLikes, +// { +// gameId: text("asset_id") +// .notNull() +// .references(() => game.id, { +// onUpdate: "cascade", +// onDelete: "cascade", +// }), +// likedById: text("liked_by_id") +// .notNull() +// .references(() => authUser.id, { +// onUpdate: "cascade", +// onDelete: "cascade", +// }), +// createdAt: text("created_at") +// .notNull() +// .$defaultFn(() => { +// return new Date().toISOString() +// }), +// }, +// (gameLikes) => { +// return { +// gameIdx: index("gamelikes_game_idx").on(gameLikes.gameId), +// likedByIdx: index("gamelikes_likedby_idx").on(gameLikes.likedById), +// } +// } +// ) + +// export type GameLikes = typeof gameLikes.$inferSelect +// export type NewGameLikes = typeof gameLikes.$inferInsert +// export const insertGameLikesSchema = createInsertSchema(gameLikes) +// export const selectGameLikesSchema = createSelectSchema(gameLikes) + +// export const gameLikesRelations = relations(gameLikes, ({ one }) => ({ +// game: one(game, { +// fields: [gameLikes.gameId], +// references: [game.id], +// relationName: "gamelikes_liked_game", +// }), +// likedBy: one(authUser, { +// fields: [gameLikes.likedById], +// references: [authUser.id], +// relationName: "gamelikes_liked_by", +// }), +// })) diff --git a/src/v2/db/schema/game/game.ts b/src/v2/db/schema/game/game.ts new file mode 100644 index 0000000..1b45efb --- /dev/null +++ b/src/v2/db/schema/game/game.ts @@ -0,0 +1,54 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + integer, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { asset } from "../asset/asset" +import { gameAssetCategory } from "../categories/asset-categories" +// import { gameLikes } from "./game-likes" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: Game relation is easy to understand and self-explanatory. +- A game can have many assets and many game asset categories. +*/ + +export const game = sqliteTable( + tableNames.game, + { + id: text("id").primaryKey().notNull(), // e.g genshin-impact, honkai-impact-3rd + name: text("name").notNull().unique(), // e.g genshin-impact, honkai-impact-3rd + formattedName: text("formatted_name").notNull(), // e.g Genshin Impact, Honkai Impact 3rd + possibleSuggestiveContent: integer("possible_suggestive_content", { + mode: "boolean", + }) + .default(false) + .notNull(), + lastUpdated: text("last_updated") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (game) => { + return { + gameIdx: index("game_id_idx").on(game.id), + nameIdx: index("game_name_idx").on(game.name), + } + } +) + +export type Game = typeof game.$inferSelect +export type NewGame = typeof game.$inferInsert +export const insertGameSchema = createInsertSchema(game) +export const selectGameSchema = createSelectSchema(game) + +export const gameRelations = relations(game, ({ many }) => ({ + asset: many(asset), + gameAssetCategory: many(gameAssetCategory), + // gameLikes: many(gameLikes), +})) diff --git a/src/v2/db/schema/supporter/request-form.ts b/src/v2/db/schema/supporter/request-form.ts new file mode 100644 index 0000000..8efc20e --- /dev/null +++ b/src/v2/db/schema/supporter/request-form.ts @@ -0,0 +1,110 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { generateID } from "@/v2/lib/oslo" +import { authUser } from "../user/user" + +export type requestArea = "asset" | "game" | "site" + +export const requestForm = sqliteTable( + tableNames.requestForm, + { + id: text("id") + .unique() + .notNull() + .$defaultFn(() => { + return generateID() + }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + area: text("type").notNull().$type(), + title: text("title").notNull(), + description: text("description").notNull(), + }, + (requestForm) => { + return { + requestFormUserIdx: index("request_form_user_id_idx").on( + requestForm.userId + ), + } + } +) + +export type RequestForm = typeof requestForm.$inferSelect +export type NewRequestForm = typeof requestForm.$inferInsert +export const insertRequestFormSchema = createInsertSchema(requestForm) +export const selectRequestFormSchema = createSelectSchema(requestForm) + +export const requestFormRelations = relations(requestForm, ({ one, many }) => ({ + user: one(authUser, { + fields: [requestForm.userId], + references: [authUser.id], + relationName: "request_form_user", + }), + upvotes: many(requestFormUpvotes), +})) + +export const requestFormUpvotes = sqliteTable( + tableNames.requestFormUpvotes, + { + id: text("id") + .unique() + .notNull() + .$defaultFn(() => { + return generateID() + }), + requestFormId: text("request_form_id") + .notNull() + .references(() => requestForm.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + }, + (requestFormUpvotes) => { + return { + requestFormUpvotesIdx: index("request_form_upvotes_idx").on( + requestFormUpvotes.requestFormId, + requestFormUpvotes.userId + ), + } + } +) + +export type RequestFormUpvotes = typeof requestFormUpvotes.$inferSelect +export type NewRequestFormUpvotes = typeof requestFormUpvotes.$inferInsert +export const insertRequestFormUpvotesSchema = + createInsertSchema(requestFormUpvotes) +export const selectRequestFormUpvotesSchema = + createSelectSchema(requestFormUpvotes) + +export const requestFormUpvotesRelations = relations( + requestFormUpvotes, + ({ one }) => ({ + user: one(authUser, { + fields: [requestFormUpvotes.userId], + references: [authUser.id], + relationName: "request_form_upvotes_user", + }), + requestForm: one(requestForm, { + fields: [requestFormUpvotes.requestFormId], + references: [requestForm.id], + relationName: "request_form_upvotes_request_form", + }), + }) +) diff --git a/src/v2/db/schema/tags/asset-tags-likes.ts b/src/v2/db/schema/tags/asset-tags-likes.ts new file mode 100644 index 0000000..e1ddbc0 --- /dev/null +++ b/src/v2/db/schema/tags/asset-tags-likes.ts @@ -0,0 +1,50 @@ +// import { tableNames } from "@/v2/db/drizzle" +// import { relations } from "drizzle-orm" +// import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +// import { authUser } from "../user/user" +// import { assetTag } from "./asset-tags" +// import { createInsertSchema, createSelectSchema } from "drizzle-zod" +// export const assetTagLikes = sqliteTable( +// tableNames.assetTagLikes, +// { +// assetTagId: text("asset_id") +// .notNull() +// .references(() => assetTag.id), +// likedById: text("liked_by_id") +// .notNull() +// .references(() => authUser.id), +// createdAt: text("created_at") +// .notNull() +// .$defaultFn(() => { +// return new Date().toISOString() +// }), +// }, +// (gameLikes) => { +// return { +// assetTagIdx: index("assetTagLikes_asset_idx").on( +// gameLikes.assetTagId +// ), +// likedByIdx: index("assetTagLikes_likedby_idx").on( +// gameLikes.likedById +// ), +// } +// } +// ) + +// export type AssetTagLikes = typeof assetTagLikes.$inferSelect +// export type NewAssetTagLikes = typeof assetTagLikes.$inferInsert +// export const insertAssetTagLikesSchema = createInsertSchema(assetTagLikes) +// export const selectAssetTagLikesSchema = createSelectSchema(assetTagLikes) + +// export const assetTagLikesRelations = relations(assetTagLikes, ({ one }) => ({ +// assetTag: one(assetTag, { +// fields: [assetTagLikes.assetTagId], +// references: [assetTag.id], +// relationName: "assetTagLikes_liked_assetTag", +// }), +// likedBy: one(authUser, { +// fields: [assetTagLikes.likedById], +// references: [authUser.id], +// relationName: "assetTagLikes_liked_by", +// }), +// })) diff --git a/src/v2/db/schema/tags/asset-tags.ts b/src/v2/db/schema/tags/asset-tags.ts new file mode 100644 index 0000000..c7da71f --- /dev/null +++ b/src/v2/db/schema/tags/asset-tags.ts @@ -0,0 +1,89 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { asset } from "../asset/asset" +// import { assetTagLikes } from "./asset-tags-likes" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: Asset tags are not stored as ENUMs to allow for better UX, flexibility, and extensibility. +- AssetTag: A tag that can be applied to an asset. +- AssetTagAsset: A join table that associates an asset with an asset tag. +*/ + +export const assetTag = sqliteTable( + tableNames.assetTag, + { + id: text("id").primaryKey().notNull(), + name: text("name").notNull().unique(), + formattedName: text("formatted_name").notNull(), + lastUpdated: text("last_updated").notNull(), + }, + (assetTag) => { + return { + assetTagIdx: index("asset_tag_id_idx").on(assetTag.id), + nameIdx: index("asset_tag_name_idx").on(assetTag.name), + } + } +) + +export type AssetTag = typeof assetTag.$inferSelect +export type NewAssetTag = typeof assetTag.$inferInsert +export const insertAssetTagSchema = createInsertSchema(assetTag) +export const selectAssetTagSchema = createSelectSchema(assetTag) + +export const assetTagAsset = sqliteTable( + tableNames.assetTagAsset, + { + assetTagId: text("asset_tag_id") + .notNull() + .references(() => assetTag.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + assetId: text("asset_id") + .notNull() + .references(() => asset.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + }, + (assetTagAsset) => { + return { + assetTagAssetAssetTagIdx: index( + "asset_tags_assets_asset_tag_id_idx" + ).on(assetTagAsset.assetTagId), + assetTagAssetAssetIdx: index("asset_tags_assets_asset_id_idx").on( + assetTagAsset.assetId + ), + } + } +) + +export type AssetTagAsset = typeof assetTagAsset.$inferSelect +export type NewAssetTagAsset = typeof assetTagAsset.$inferInsert +export const insertAssetTagAssetSchema = createInsertSchema(assetTagAsset) +export const selectAssetTagAssetSchema = createSelectSchema(assetTagAsset) + +export const assetTagRelations = relations(assetTag, ({ many }) => ({ + assetTagAsset: many(assetTagAsset), + // assetTagLikes: many(assetTagLikes), +})) + +export const assetTagAssetRelations = relations(assetTagAsset, ({ one }) => ({ + assetTag: one(assetTag, { + fields: [assetTagAsset.assetTagId], + references: [assetTag.id], + relationName: "assettagasset_assettag", + }), + asset: one(asset, { + fields: [assetTagAsset.assetId], + references: [asset.id], + relationName: "assettagasset_asset", + }), +})) diff --git a/src/v2/db/schema/user/user-attributes.ts b/src/v2/db/schema/user/user-attributes.ts new file mode 100644 index 0000000..43038a0 --- /dev/null +++ b/src/v2/db/schema/user/user-attributes.ts @@ -0,0 +1,117 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "./user" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: This is mostly security related. +- Such as when a user forgets their password, they can request a password reset token. +- Or, they can verify their e-mail if they didn't use an OAuth method which returns something like `email_verified`. +*/ + +export const emailVerificationToken = sqliteTable( + tableNames.emailVerificationToken, + { + id: text("id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + token: text("token").notNull(), + expiresAt: text("expires_at") + .notNull() + .$defaultFn(() => { + const now = new Date() + now.setHours(now.getHours() + 12) + return now.toISOString() + }), + }, + (emailVerificationToken) => { + return { + userIdx: index("email_verification_token_user_id_idx").on( + emailVerificationToken.userId + ), + tokenIdx: index("email_verification_token_token_idx").on( + emailVerificationToken.token + ), + } + } +) + +export type EmailVerificationToken = typeof emailVerificationToken.$inferSelect +export type NewEmailVerificationToken = + typeof emailVerificationToken.$inferInsert +export const insertEmailVerificationTokenSchema = createInsertSchema( + emailVerificationToken +) +export const selectEmailVerificationTokenSchema = createSelectSchema( + emailVerificationToken +) + +export const passwordResetToken = sqliteTable( + tableNames.passwordResetToken, + { + id: text("id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + token: text("token").notNull(), + expiresAt: text("expires_at") + .notNull() + .$defaultFn(() => { + const now = new Date() + now.setHours(now.getHours() + 12) + return now.toISOString() + }), + }, + (passwordResetToken) => { + return { + userIdx: index("password_reset_token_user_id_idx").on( + passwordResetToken.userId + ), + tokenIdx: index("password_reset_token_token_idx").on( + passwordResetToken.token + ), + } + } +) + +export type PasswordResetToken = typeof passwordResetToken.$inferSelect +export type NewPasswordResetToken = typeof passwordResetToken.$inferInsert +export const insertPasswordResetTokenSchema = + createInsertSchema(passwordResetToken) +export const selectPasswordResetTokenSchema = + createSelectSchema(passwordResetToken) + +export const emailVerificationTokenRelations = relations( + emailVerificationToken, + ({ one }) => ({ + user: one(authUser, { + fields: [emailVerificationToken.userId], + references: [authUser.id], + relationName: "emailverificationtoken_user", + }), + }) +) + +export const passwordResetTokenRelations = relations( + passwordResetToken, + ({ one }) => ({ + user: one(authUser, { + fields: [passwordResetToken.userId], + references: [authUser.id], + relationName: "passwordresettoken_user", + }), + }) +) diff --git a/src/v2/db/schema/user/user-blocked.ts b/src/v2/db/schema/user/user-blocked.ts new file mode 100644 index 0000000..55312ce --- /dev/null +++ b/src/v2/db/schema/user/user-blocked.ts @@ -0,0 +1,64 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "./user" +import { generateID } from "@/v2/lib/oslo" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +export const userBlocked = sqliteTable( + tableNames.userBlocked, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID() + }), + blockedById: text("blocked_by_id") + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + blockedId: text("blocked_id") + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + }, + (table) => { + return { + idIdx: index("user_blocked_id_idx").on(table.id), + blockedByIdIdx: index("user_blocked_blocked_by_id_idx").on( + table.blockedById + ), + blockedIdIdx: index("user_blocked_blocked_id_idx").on( + table.blockedId + ), + } + } +) + +export type UserBlocked = typeof userBlocked.$inferSelect +export type NewUserBlocked = typeof userBlocked.$inferInsert +export const insertUserBlockedSchema = createInsertSchema(userBlocked) +export const selectUserBlockedSchema = createSelectSchema(userBlocked) + +export const userBlockedRelations = relations(userBlocked, ({ one }) => ({ + blockedBy: one(authUser, { + fields: [userBlocked.blockedById], + references: [authUser.id], + relationName: "blockedBy", + }), + blocked: one(authUser, { + fields: [userBlocked.blockedId], + references: [authUser.id], + relationName: "blocked", + }), +})) diff --git a/src/v2/db/schema/user/user-connections.ts b/src/v2/db/schema/user/user-connections.ts new file mode 100644 index 0000000..1e6f565 --- /dev/null +++ b/src/v2/db/schema/user/user-connections.ts @@ -0,0 +1,53 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { authUser } from "./user" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: This file will be expanded on in the future, but for now it's just for Discord. +- This is used to link an account to ID but also can be set by initial Discord OAuth. +*/ + +export const socialsConnection = sqliteTable( + tableNames.socialsConnection, + { + id: text("id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .unique(), + discordId: text("discord_id"), + }, + (socialsConnection) => { + return { + userIdx: index("socials_connection_user_id_idx").on( + socialsConnection.userId + ), + discordIdIdx: index("socials_connection_discord_id_idx").on( + socialsConnection.discordId + ), + } + } +) + +export type SocialsConnection = typeof socialsConnection.$inferSelect +export type NewSocialsConnection = typeof socialsConnection.$inferInsert +export const insertSocialsConnectionSchema = + createInsertSchema(socialsConnection) +export const selectSocialsConnectionSchema = + createSelectSchema(socialsConnection) + +export const socialsConnectionRelations = relations( + socialsConnection, + ({ one }) => ({ + user: one(authUser, { + fields: [socialsConnection.userId], + references: [authUser.id], + relationName: "socials_connection_user", + }), + }) +) diff --git a/src/v2/db/schema/user/user-following.ts b/src/v2/db/schema/user/user-following.ts new file mode 100644 index 0000000..a95f244 --- /dev/null +++ b/src/v2/db/schema/user/user-following.ts @@ -0,0 +1,66 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { authUser } from "./user" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" + +/* +NOTE: this file manages the "social" aspect of users. +- This is where users can follow other users, and be followed by other users. +*/ + +export const userFollowing = sqliteTable( + tableNames.userFollowing, + { + followerId: text("followerId") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + followingId: text("followingId") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + }, + (userFollowing) => { + return { + followerIdx: index("userfollowing_follower_idx").on( + userFollowing.followerId + ), + followingIdx: index("userfollowing_following_idx").on( + userFollowing.followingId + ), + } + } +) + +export type UserFollowing = typeof userFollowing.$inferSelect +export type NewUserFollowing = typeof userFollowing.$inferInsert +export const insertUserFollowingSchema = createInsertSchema(userFollowing) +export const selectUserFollowingSchema = createSelectSchema(userFollowing) + +export const userFollowingRelations = relations(userFollowing, ({ one }) => ({ + follower: one(authUser, { + fields: [userFollowing.followerId], + references: [authUser.id], + relationName: "follower", + }), + following: one(authUser, { + fields: [userFollowing.followingId], + references: [authUser.id], + relationName: "following", + }), +})) diff --git a/src/v2/db/schema/user/user.ts b/src/v2/db/schema/user/user.ts new file mode 100644 index 0000000..6081d82 --- /dev/null +++ b/src/v2/db/schema/user/user.ts @@ -0,0 +1,250 @@ +import { tableNames } from "@/v2/db/drizzle" +import { relations } from "drizzle-orm" +import { + sqliteTable, + text, + integer, + // uniqueIndex, + index, +} from "drizzle-orm/sqlite-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { generateID } from "@/v2/lib/oslo" +import { userFollowing } from "./user-following" +import { asset } from "../asset/asset" +import { socialsConnection } from "./user-connections" +import { userCollection } from "../collections/user-collections" +import { passwordResetToken } from "./user-attributes" +import { emailVerificationToken } from "./user-attributes" +import { assetExternalFile } from "../asset/asset-external-files" +import { userCollectionLikes } from "../collections/user-collection-likes" +import { userCollectionCollaborators } from "../collections/user-collections-collaborators" +import { assetLikes } from "../asset/asset-likes" +// import { gameLikes } from "../game/game-likes" +// import { assetTagLikes } from "../tags/asset-tags-likes" +// import { assetCategoryLikes } from "../categories/asset-categories-likes" +import { requestForm, requestFormUpvotes } from "../supporter/request-form" +import { userBlocked } from "./user-blocked" +import { assetComments } from "../asset/asset-comments" + +/* +NOTE: Very basic user information +- Users table is user information +- Keys table is login methods (i.e Credentials, OAuth, etc.) +*/ + +export type UserRoles = + | "creator" + | "staff" + | "contributor" + | "uploader" + | "user" + +export type UserPlan = "free" | "supporter" + +export const authUser = sqliteTable( + tableNames.authUser, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID() + }), + avatarUrl: text("avatar_url"), + bannerUrl: text("banner_url"), + displayName: text("display_name"), + username: text("username").notNull().unique(), + usernameColour: text("username_colour"), + email: text("email").notNull(), + emailVerified: integer("email_verified").default(0).notNull(), + pronouns: text("pronouns"), + verified: integer("verified").default(0).notNull(), + bio: text("bio").default("No bio set").notNull(), + dateJoined: text("date_joined") + .notNull() + .$defaultFn(() => { + return new Date().toISOString() + }), + plan: text("plan").default("free").notNull().$type(), + isBanned: integer("is_banned", { mode: "boolean" }) + .default(false) + .notNull(), + isContributor: integer("is_contributor", { mode: "boolean" }) + .default(false) + .notNull(), + role: text("role").notNull().default("user").$type(), + }, + (user) => { + return { + userIdx: index("user_id_idx").on(user.id), + usernameIdx: index("user_username_idx").on(user.username), + emailIdx: index("user_email_idx").on(user.email), + contributorIdx: index("user_contributor_idx").on( + user.isContributor + ), + } + } +) + +export type User = typeof authUser.$inferSelect +export type NewUser = typeof authUser.$inferInsert +export const insertUserSchema = createInsertSchema(authUser) +export const selectUserSchema = createSelectSchema(authUser) + +export const stripeUser = sqliteTable( + tableNames.stripeUser, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID() + }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + customerId: text("stripe_customer_id"), + subscriptionId: text("stripe_subscription_id"), + endsAt: text("ends_at"), + paidUntil: text("paid_until"), + }, + (stripeUser) => { + return { + userIdx: index("stripe_user_user_id_idx").on(stripeUser.userId), + stripeCustomerIdIdx: index("stripe_user_stripe_customer_id_idx").on( + stripeUser.customerId + ), + } + } +) + +export type StripeUser = typeof stripeUser.$inferSelect +export type NewStripeUser = typeof stripeUser.$inferInsert +export const insertStripeUserSchema = createInsertSchema(stripeUser) +export const selectStripeUserSchema = createSelectSchema(stripeUser) + +export const authCredentials = sqliteTable( + tableNames.authCredentials, + { + id: text("id") + .primaryKey() + .notNull() + .$defaultFn(() => { + return generateID(20) + }), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + hashedPassword: text("hashed_password"), + }, + (key) => { + return { + userIdx: index("key_user_id_idx").on(key.userId), + } + } +) + +export type AuthCredentials = typeof authCredentials.$inferSelect +export type NewAuthCredentials = typeof authCredentials.$inferInsert +export const insertAuthCredentialsSchema = createInsertSchema(authCredentials) +export const selectAuthCredentialsSchema = createSelectSchema(authCredentials) + +// interface Session extends SessionAttributes { +// id: string; +// userId: string; +// expiresAt: Date; +// fresh: boolean; +// } + +export const userSession = sqliteTable( + tableNames.authSession, + { + id: text("id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .references(() => authUser.id, { + onUpdate: "cascade", + onDelete: "cascade", + }), + expiresAt: text("expires_at").notNull(), + userAgent: text("user_agent").notNull(), + countryCode: text("country_code").notNull(), + ipAddress: text("ip_address").notNull(), + }, + (session) => { + return { + userIdx: index("session_user_id_idx").on(session.userId), + } + } +) + +export type Session = typeof userSession.$inferSelect +export type NewSession = typeof userSession.$inferInsert +export const insertSessionSchema = createInsertSchema(userSession) +export const selectSessionSchema = createSelectSchema(userSession) + +export const usersRelations = relations(authUser, ({ one, many }) => ({ + follower: many(userFollowing, { + relationName: "follower", + }), + following: many(userFollowing, { + relationName: "following", + }), + blocked: many(userBlocked, { + relationName: "blocked", + }), + blockedBy: many(userBlocked, { + relationName: "blockedBy", + }), + authCredentials: one(authCredentials), + userSession: many(userSession), + asset: many(asset), + assetExternalFile: many(assetExternalFile), + userCollectionLikes: many(userCollectionLikes), + assetLikes: many(assetLikes), + assetComments: many(assetComments), + socialsConnection: one(socialsConnection), + userCollection: many(userCollection), + stripeUser: one(stripeUser), + passwordResetToken: one(passwordResetToken), + emailVerificationToken: one(emailVerificationToken), + // gameLikes: many(gameLikes), + // assetTagLikes: many(assetTagLikes), + // assetCategoryLikes: many(assetCategoryLikes), + userCollectionCollaborators: many(userCollectionCollaborators), + requestForm: many(requestForm), + requestFormUpvotes: many(requestFormUpvotes), +})) + +export const stripeUserRelations = relations(stripeUser, ({ one }) => ({ + user: one(authUser, { + fields: [stripeUser.userId], + references: [authUser.id], + relationName: "stripe_user", + }), +})) + +export const authCredentialsRelations = relations( + authCredentials, + ({ one }) => ({ + user: one(authUser, { + fields: [authCredentials.userId], + references: [authUser.id], + relationName: "key_auth_user", + }), + }) +) + +export const sessionRelations = relations(userSession, ({ one }) => ({ + user: one(authUser, { + fields: [userSession.userId], + references: [authUser.id], + relationName: "session_auth_user", + }), +})) diff --git a/src/v2/db/turso.ts b/src/v2/db/turso.ts new file mode 100644 index 0000000..c04b785 --- /dev/null +++ b/src/v2/db/turso.ts @@ -0,0 +1,53 @@ +import { drizzle as drizzleORM } from "drizzle-orm/libsql" +import { createClient } from "@libsql/client/web" // because we're in a worker +import { Logger } from "drizzle-orm/logger" + +import * as schema from "@/v2/db/schema" + +/** + * The `LoggerWrapper` class is used to wrap the `Logger` interface from `drizzle-orm` and provide a custom implementation of the `logQuery` method. + * It logs the query and its parameters to the console. + */ +class LoggerWrapper implements Logger { + // TODO(dromzeh): this is useful to log; should probably be logged elsewhere + logQuery(query: string, params: unknown[]): void { + console.log(`DRIZZLE: Query: ${query}, Parameters: ${params ?? "none"}`) + } +} + +/** + * The `getConnection` function is used to create a connection to the Turso database and initialize a `drizzle-orm` instance. + * @param env - The environment variables used to configure the connection. + * @returns An object containing the `drizzle-orm` instance and the Turso client. + */ +export function getConnection(env: Bindings) { + /** + * The `createClient` function is used to create a Turso client. + * The `url` option is set to the `TURSO_DATABASE_URL` environment variable. + * The `authToken` option is set to the `TURSO_DATABASE_AUTH_TOKEN` environment variable. + **/ + + const turso = createClient({ + url: env.TURSO_DATABASE_URL!, + authToken: env.TURSO_DATABASE_AUTH_TOKEN!, + }) + + /** + * Drizzle instance is initialized with the `turso` client and database `schema`. + * The `LoggerWrapper` is passed to the `logger` option to log queries to the console. + */ + const drizzle = drizzleORM(turso, { + schema: { + ...schema, + }, + logger: new LoggerWrapper(), + }) + + return { + drizzle, + turso, + } +} + +export type DrizzleInstance = ReturnType["drizzle"] +export type TursoInstance = ReturnType["turso"] diff --git a/src/v2/lib/auth/definitions/auth-definitions.ts b/src/v2/lib/auth/definitions/auth-definitions.ts new file mode 100644 index 0000000..ba7da39 --- /dev/null +++ b/src/v2/lib/auth/definitions/auth-definitions.ts @@ -0,0 +1,31 @@ +import { UserRoles } from "@/v2/db/schema" +import type { LuciaAuth } from "../lucia" +import type { UserPlan } from "@/v2/db/schema" + +declare module "lucia" { + interface Register { + Lucia: LuciaAuth + DatabaseSessionAttributes: { + user_agent: string + country_code: string + ip_address: string + } + DatabaseUserAttributes: { + avatar_url: string | null + banner_url: string | null + display_name: string | null + username: string + username_colour: string | null + email: string + email_verified: boolean + pronouns: string | null + verified: boolean + bio: string | null + date_joined: string + plan: UserPlan + is_banned: boolean + role: UserRoles + is_contributor: boolean + } + } +} diff --git a/src/v2/lib/auth/lucia.ts b/src/v2/lib/auth/lucia.ts new file mode 100644 index 0000000..3ed4b32 --- /dev/null +++ b/src/v2/lib/auth/lucia.ts @@ -0,0 +1,63 @@ +import { Lucia } from "lucia" +import { getConnection } from "@/v2/db/turso" +import { tableNames } from "@/v2/db/drizzle" +import { LibSQLAdapter } from "@lucia-auth/adapter-sqlite" +import { UserRoles } from "@/v2/db/schema" + +export function luciaAuth(env: Bindings) { + const { turso } = getConnection(env) + + return new Lucia( + // i can't get the drizzle adapter working at all (works fine with SQLite3) + // and i don't really have time to write my own rn , so i'm just gonna use the sqlite adapter + // it is what it is :3 + new LibSQLAdapter(turso, { + user: tableNames.authUser, + session: tableNames.authSession, + }), + { + getUserAttributes: (user) => { + return { + avatarUrl: user.avatar_url, + bannerUrl: user.banner_url, + displayName: user.display_name, + username: user.username, + usernameColour: user.username_colour, + email: user.email, + emailVerified: Boolean(user.email_verified), + pronouns: user.pronouns, + verified: user.verified, + bio: user.bio, + dateJoined: user.date_joined, + plan: user.plan, + isBanned: Boolean(user.is_banned), + isContributor: user.is_contributor, + role: user.role as UserRoles, + } + }, + getSessionAttributes: (session) => { + return { + userAgent: session.user_agent, + countryCode: session.country_code, + ipAddress: session.ip_address, + } + }, + sessionCookie: { + name: "user_auth_session", + // i don't really see too much of a security concern with making the session cookie indefinite. + // it's very unlikely that someone will be able to steal a session cookie, and if they do, it's + // already handled with comparing IP/UA/Country Code. it expires the session + // and it can just state that the session was logged out due to a security concern(and the user can just log back in) + // plus i feel this is better user experience in general lol + expires: false, + attributes: { + secure: true, + sameSite: "strict", + path: "/", + }, + }, + } + ) +} + +export type LuciaAuth = ReturnType diff --git a/src/v2/lib/auth/oauth/discord.ts b/src/v2/lib/auth/oauth/discord.ts new file mode 100644 index 0000000..039e845 --- /dev/null +++ b/src/v2/lib/auth/oauth/discord.ts @@ -0,0 +1 @@ +// TODO: discord oauth config diff --git a/src/v2/lib/colour.ts b/src/v2/lib/colour.ts new file mode 100644 index 0000000..e49f208 --- /dev/null +++ b/src/v2/lib/colour.ts @@ -0,0 +1,5 @@ +export type ColourType = `#${string}` + +export function isValidColour(colour: string): colour is ColourType { + return /^#[0-9A-F]{6}$/i.test(colour) +} diff --git a/src/lib/discord.ts b/src/v2/lib/discord.ts similarity index 88% rename from src/lib/discord.ts rename to src/v2/lib/discord.ts index 8dac8b4..0a273c9 100644 --- a/src/lib/discord.ts +++ b/src/v2/lib/discord.ts @@ -7,6 +7,6 @@ export const roles: { [key: string]: string } = { "1088105796908355584": "Translator", "1005805438031364129": "Contributor", "983883539772751912": "Server Booster", -}; +} -export const guildId = "982385887000272956"; +export const guildId = "982385887000272956" diff --git a/src/v2/lib/helpers/check-image-tags.ts b/src/v2/lib/helpers/check-image-tags.ts new file mode 100644 index 0000000..99b2993 --- /dev/null +++ b/src/v2/lib/helpers/check-image-tags.ts @@ -0,0 +1,39 @@ +type ModerationResponse = { + moderationLabels: { + labels: { + name: string + confidence: number + }[] + }[] +} + +export async function CheckLabels( + ctx: APIContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + image: any // yolo +): Promise { + if (ctx.env.ENVIRONMENT === "DEV") return + + try { + const res = await fetch( + "https://aws-moderation.dromzeh.workers.dev/labels", + { + method: "POST", + body: image, + headers: { + Authorization: `Bearer ${ctx.env.REKOGNITION_LABEL_API_KEY}`, + }, + } + ) + + if (res.status !== 200) { + throw new Error("Failed to check image labels") + } + + const data = (await res.json()) as ModerationResponse + + return data.moderationLabels.length > 0 + } catch (error) { + throw new Error("Failed to check image labels") + } +} diff --git a/src/v2/lib/helpers/rename.ts b/src/v2/lib/helpers/rename.ts new file mode 100644 index 0000000..7747d05 --- /dev/null +++ b/src/v2/lib/helpers/rename.ts @@ -0,0 +1,8 @@ +/** + * Replaces all hyphens in a string with underscores. + * @param name - The string to rename. + * @returns The renamed string with all hyphens replaced by underscores. + */ +export const rename = (name: string): string => { + return name.replace(/-/g, "_") +} diff --git a/src/v2/lib/helpers/split-query-by-commas.ts b/src/v2/lib/helpers/split-query-by-commas.ts new file mode 100644 index 0000000..896b6a0 --- /dev/null +++ b/src/v2/lib/helpers/split-query-by-commas.ts @@ -0,0 +1,8 @@ +/** + * Splits a query string by commas and trims each resulting string. + * @param query - The query string to split. + * @returns An array of strings resulting from splitting the query string by commas and trimming each resulting string. + */ +export function SplitQueryByCommas(query: string): string[] { + return query.split(",").map((q) => q.trim()) +} diff --git a/src/v2/lib/helpers/status-codes.ts b/src/v2/lib/helpers/status-codes.ts new file mode 100644 index 0000000..223e69c --- /dev/null +++ b/src/v2/lib/helpers/status-codes.ts @@ -0,0 +1,31 @@ +export class StatusCodes { + static StatusCodes2XX = { + OK: { code: 200, description: "OK" }, + CREATED: { code: 201, description: "Created" }, + NO_CONTENT: { code: 204, description: "No Content" }, + } + + static StatusCodes4XX = { + BAD_REQUEST: { code: 400, description: "Bad Request" }, + UNAUTHORIZED: { code: 401, description: "Unauthorized" }, + FORBIDDEN: { code: 403, description: "Forbidden" }, + NOT_FOUND: { code: 404, description: "Not Found" }, + METHOD_NOT_ALLOWED: { code: 405, description: "Method Not Allowed" }, + TOO_MANY_REQUESTS: { code: 429, description: "Too Many Requests" }, + } + + static StatusCodes5XX = { + INTERNAL_SERVER_ERROR: { + code: 500, + description: "Internal Server Error", + }, + SERVICE_UNAVAILABLE: { code: 503, description: "Service Unavailable" }, + GATEWAY_TIMEOUT: { code: 504, description: "Gateway Timeout" }, + } + + static All = { + ...StatusCodes.StatusCodes2XX, + ...StatusCodes.StatusCodes4XX, + ...StatusCodes.StatusCodes5XX, + } +} diff --git a/src/v2/lib/list-bucket.ts b/src/v2/lib/list-bucket.ts new file mode 100644 index 0000000..19b572b --- /dev/null +++ b/src/v2/lib/list-bucket.ts @@ -0,0 +1,4 @@ +// i dont even know why this is a thing +export const listBucket = async (bucket: Bindings["FILES_BUCKET"], options) => { + return await bucket.list(options) +} diff --git a/src/v2/lib/managers/auth/user-auth-manager.ts b/src/v2/lib/managers/auth/user-auth-manager.ts new file mode 100644 index 0000000..8fcbd91 --- /dev/null +++ b/src/v2/lib/managers/auth/user-auth-manager.ts @@ -0,0 +1,123 @@ +import { luciaAuth } from "../../auth/lucia" +import { Scrypt } from "lucia" +import { getConnection } from "@/v2/db/turso" +import { authCredentials, authUser } from "@/v2/db/schema" +import { createInsertSchema } from "drizzle-zod" +import { z } from "zod" +import { eq, or } from "drizzle-orm" + +const authUserInsertSchema = createInsertSchema(authUser).pick({ + username: true, + email: true, +}) + +const USER_AGENT = "user-agent" +const CONNECTING_IP = "cf-connecting-ip" +const IP_COUNTRY = "cf-ipcountry" + +export class UserAuthenticationManager { + private lucia: ReturnType + private drizzle: ReturnType["drizzle"] + + constructor(private ctx: APIContext) { + this.lucia = luciaAuth(this.ctx.env) + this.drizzle = getConnection(this.ctx.env).drizzle + } + + private async checkForExistingUser( + attributes: Required> + ) { + const [existingUser] = await this.drizzle + .select({ id: authUser.id }) + .from(authUser) + .where( + or( + eq(authUser.username, attributes.username), + eq(authUser.email, attributes.email) + ) + ) + + return existingUser ? true : false + } + + private async createSessionAndCookie(userId: string) { + const newSession = await this.lucia.createSession(userId, { + user_agent: this.ctx.req.header(USER_AGENT) || "", + ip_address: this.ctx.req.header(CONNECTING_IP) || "", + country_code: this.ctx.req.header(IP_COUNTRY) || "", + }) + + return this.lucia.createSessionCookie(newSession.id) + } + + public async createAccount( + attributes: Required>, + password?: string + ) { + const existingUser = await this.checkForExistingUser(attributes) + + if (existingUser) { + return null + } + + let newUser: typeof authUser.$inferSelect + try { + const createUserTransaction = await this.drizzle.transaction( + async (db) => { + const [newUser] = await db + .insert(authUser) + .values({ + username: attributes.username, + email: attributes.email, + }) + .returning() + + if (password) { + await db.insert(authCredentials).values({ + userId: newUser.id, + hashedPassword: await new Scrypt().hash(password), + }) + } + + return newUser + } + ) + newUser = createUserTransaction + } catch (e) { + throw new Error("Failed to create user") + } + + return this.createSessionAndCookie(newUser.id) + } + + public async loginViaPassword(email: string, password: string) { + const [foundUser] = await this.drizzle + .select({ id: authUser.id, email: authUser.email }) + .from(authUser) + .where(eq(authUser.email, email)) + + if (!foundUser) { + return null + } + + const [credentials] = await this.drizzle + .select() + .from(authCredentials) + .where(eq(authCredentials.userId, foundUser.id)) + + if (!credentials) { + return null + } + + const validPassword = await new Scrypt().verify( + credentials.hashedPassword, + password + ) + + if (!validPassword) { + return null + } + + return this.createSessionAndCookie(foundUser.id) + } +} diff --git a/src/v2/lib/managers/auth/user-session-manager.ts b/src/v2/lib/managers/auth/user-session-manager.ts new file mode 100644 index 0000000..f010cb0 --- /dev/null +++ b/src/v2/lib/managers/auth/user-session-manager.ts @@ -0,0 +1,82 @@ +import { luciaAuth } from "../../auth/lucia" +import type { Session, User } from "lucia" +import { getCookie } from "hono/cookie" + +// const USER_AGENT = "user-agent" +// const CONNECTING_IP = "cf-connecting-ip" +// const IP_COUNTRY = "cf-ipcountry" + +export class AuthSessionManager { + private lucia: ReturnType + private sessionCookie: string | undefined + + constructor(private ctx: APIContext) { + this.lucia = luciaAuth(this.ctx.env) + this.sessionCookie = getCookie(this.ctx, this.lucia.sessionCookieName) + } + + private async validateAndGetSession(): Promise<{ + user: User | null + session: Session | null + }> { + if (!this.sessionCookie) { + return { user: null, session: null } + } + + const { user, session } = await this.lucia.validateSession( + this.sessionCookie + ) + + return { user: user ? user : null, session: session ? session : null } + } + + public async validateSession() { + return this.validateAndGetSession() + } + + public async getAllSessions() { + const { user } = await this.validateAndGetSession() + + if (!user) { + return null + } + + return await this.lucia.getUserSessions(user.id) + } + + public async invalidateCurrentSession() { + const { session } = await this.validateAndGetSession() + + if (!session) { + return null + } + + await this.lucia.invalidateSession(session.id) + + return true + } + + public async invalidateAllSessions() { + const { user } = await this.validateAndGetSession() + + if (!user) { + return null + } + + await this.lucia.invalidateUserSessions(user.id) + + return true + } + + public async invalidateSessionById(id: string) { + const { user } = await this.validateAndGetSession() + + if (!user) { + return null + } + + await this.lucia.invalidateSession(id) + + return true + } +} diff --git a/src/v2/lib/oslo.ts b/src/v2/lib/oslo.ts new file mode 100644 index 0000000..2a2fc1b --- /dev/null +++ b/src/v2/lib/oslo.ts @@ -0,0 +1,5 @@ +import { alphabet, generateRandomString } from "oslo/crypto" + +export function generateID(length: number = 15) { + return generateRandomString(length, alphabet("a-z", "0-9")).toLowerCase() +} diff --git a/src/v2/lib/resend/email.ts b/src/v2/lib/resend/email.ts new file mode 100644 index 0000000..e994e6f --- /dev/null +++ b/src/v2/lib/resend/email.ts @@ -0,0 +1,80 @@ +import { type EmailData, Resend } from "@/v2/lib/resend/wrapper" +import type { Context } from "hono" + +const emailFrom = "Test " + +const sendEmail = async (emailData: EmailData, ctx: Context) => { + try { + const resend = new Resend(ctx) + await resend.sendEmail(emailData) + } catch (error) { + throw new Error(`[RESEND]: ${error.message}`) + } +} + +const createEmailData = ( + to: string, + subject: string, + html: string +): EmailData => { + return { + from: emailFrom, + to, + subject, + html, + } +} + +export const sendPasswordResetEmail = async ( + email: string, + link: string, + username: string, + ctx: Context +) => { + const emailData = createEmailData( + email, + "Password Reset Request", + `Password reset for ${username}
Click here to reset your password` + ) + return sendEmail(emailData, ctx) +} + +export const sendPasswordChangeEmail = async ( + email: string, + username: string, + ctx: Context +) => { + const emailData = createEmailData( + email, + "Password Change Request", + `Your password for ${username} has been changed.
Wasn't you? Contact us at support@wanderer.moe` + ) + return sendEmail(emailData, ctx) +} + +export const sendEmailChangeEmail = async ( + email: string, + username: string, + ctx: Context +) => { + const emailData = createEmailData( + email, + "Email Change Request", + `Your email address for ${username} has been changed.
Wasn't you? Contact us at support@wanderer.moe` + ) + return sendEmail(emailData, ctx) +} + +export const sendEmailConfirmationEmail = async ( + email: string, + link: string, + username: string, + ctx: Context +) => { + const emailData = createEmailData( + email, + "Email Confirmation", + `Email confirmation for ${username}
Click here to confirm your email` + ) + return sendEmail(emailData, ctx) +} diff --git a/src/v2/lib/resend/wrapper.ts b/src/v2/lib/resend/wrapper.ts new file mode 100644 index 0000000..50fea64 --- /dev/null +++ b/src/v2/lib/resend/wrapper.ts @@ -0,0 +1,45 @@ +import type { Context } from "hono" + +export type EmailData = { + to: string + from: string + subject: string + html: string +} + +export class Resend { + private context: Context + + constructor(context: Context) { + this.context = context + } + + async sendEmail(emailData: EmailData): Promise { + const response = await fetch(`https://api.resend.com/emails`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.context.env.RESEND_API_KEY}`, + }, + body: JSON.stringify({ ...emailData }), + }) + + const contentType = response.headers.get("content-type") || "" + + if (contentType.includes("application/json")) { + const responseBody = JSON.stringify(await response.json()) + return new Response(responseBody, { + headers: { + "content-type": "application/json", + }, + }) + } else { + const responseBody = await response.text() + return new Response(responseBody, { + headers: { + "content-type": contentType, + }, + }) + } + } +} diff --git a/src/lib/responseHeaders.ts b/src/v2/lib/response-headers.ts similarity index 69% rename from src/lib/responseHeaders.ts rename to src/v2/lib/response-headers.ts index 42ccd2c..381c171 100644 --- a/src/lib/responseHeaders.ts +++ b/src/v2/lib/response-headers.ts @@ -2,6 +2,4 @@ export const responseHeaders: Record = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "content-type": "application/json;charset=UTF-8", - "access-control-allow-origin": "*", - "Cache-Control": `max-age=${60 * 60 * 12}`, -}; +} diff --git a/src/v2/lib/response-schemas.ts b/src/v2/lib/response-schemas.ts new file mode 100644 index 0000000..0ba4d27 --- /dev/null +++ b/src/v2/lib/response-schemas.ts @@ -0,0 +1,112 @@ +import { z } from "zod" +import type { createRoute } from "@hono/zod-openapi" + +// 400 +export const BadRequestSchema = z.object({ + success: z.literal(false), + message: z.string(), +}) + +// 500 +export const InternalServerErrorSchema = z.object({ + success: z.literal(false), + message: z.string(), +}) + +// 401 +export const UnauthorizedSchema = z.object({ + success: z.literal(false), + message: z.string(), +}) + +// 403 +export const ForbiddenSchema = z.object({ + success: z.literal(false), + message: z.string(), +}) + +// 404 +export const NotFoundSchema = z.object({ + success: z.literal(false), + message: z.string(), +}) + +type MockRoute = + ReturnType extends { responses: infer R } ? R : never + +export type GenericResponsesType = { + [K in keyof MockRoute]: MockRoute[K] +} + +export const openAPIResponseHeaders = z.object({ + "Access-Control-Allow-Origin": z.string().openapi({ + example: "*", + description: "The origin of the request", + }), + "Access-Control-Allow-Credentials": z.string().openapi({ + example: "true", + description: "Whether or not the request can include credentials", + }), + "X-Ratelimit-Limit": z.string().openapi({ + example: "100", + description: + "The maximum number of requests that the consumer is permitted to make", + }), + "X-Ratelimit-Remaining": z.string().openapi({ + example: "99", + description: + "The number of requests remaining in the current rate limit window", + }), + "X-Ratelimit-Reset": z.string().openapi({ + example: "59", + description: "The time at which the current rate limit window resets", + }), + "X-Ratelimit-Policy": z.string().openapi({ + example: "rate-limit-100-60", + description: "The policy used to rate limit the request", + }), +}) + +// while not all routes will utilize every one of these responses - i consolidated them here for comprehensive error handling lol +export const GenericResponses = { + 400: { + description: "Bad request", + content: { + "application/json": { + schema: BadRequestSchema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: UnauthorizedSchema, + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: ForbiddenSchema, + }, + }, + }, + 429: { + description: "Rate limited", + content: { + "application/json": { + schema: BadRequestSchema, + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: InternalServerErrorSchema, + }, + }, + }, +} diff --git a/src/v2/lib/types/discord.ts b/src/v2/lib/types/discord.ts new file mode 100644 index 0000000..e8b7f6b --- /dev/null +++ b/src/v2/lib/types/discord.ts @@ -0,0 +1,18 @@ +// types used for the /contributor endpoint +export interface Contributor { + id: string + username: string + globalname: string | null + avatar: string + roles: string[] +} + +export interface GuildMember { + roles: string[] + user: { + id: string + username: string + global_name: string | null + avatar: string + } +} diff --git a/src/v2/lib/types/oc-generator.ts b/src/v2/lib/types/oc-generator.ts new file mode 100644 index 0000000..00fc1c1 --- /dev/null +++ b/src/v2/lib/types/oc-generator.ts @@ -0,0 +1,6 @@ +export type OCGeneratorResponse = { + options: { + name: string + entries: string[] + }[] +} diff --git a/src/v2/middleware/ratelimit/limiter.ts b/src/v2/middleware/ratelimit/limiter.ts new file mode 100644 index 0000000..db51467 --- /dev/null +++ b/src/v2/middleware/ratelimit/limiter.ts @@ -0,0 +1,102 @@ +import dayjs from "dayjs" +import { Context, MiddlewareHandler } from "hono" + +const fakeDomain = "http://fake.wanderer.moe/" + +const getRateLimitKey = (ctx: Context) => { + const ip = ctx.req.header("cf-connecting-ip") + // TODO(dromzeh): look into setting current user w/ ctx.get/set, then we can use that OVER user ip? idk + const uniqueKey = ip ?? "unknown" + return uniqueKey +} + +const getCacheKey = ( + endpoint: string, + key: number | string, + limit: number, + interval: number +) => { + return `${fakeDomain}${endpoint}/${key}/${limit}/${interval}` +} + +const setRateLimitHeaders = ( + ctx: Context, + secondsExpires: number, + limit: number, + remaining: number, + interval: number +) => { + ctx.header("X-RateLimit-Limit", limit.toString()) + ctx.header("X-RateLimit-Remaining", remaining.toString()) + ctx.header("X-RateLimit-Reset", secondsExpires.toString()) + ctx.header("X-RateLimit-Policy", `rate-limit-${limit}-${interval}`) +} + +export const rateLimit = ( + interval: number, + limit: number +): MiddlewareHandler<{ Bindings: Bindings }> => { + return async (ctx, next) => { + const key = getRateLimitKey(ctx) + + const endpoint = new URL(ctx.req.url).pathname + + const id = ctx.env.RATE_LIMITER.idFromName(key) + const rateLimiter = ctx.env.RATE_LIMITER.get(id) + + const cache = await caches.open("rate-limiter") + const cacheKey = getCacheKey(endpoint, key, limit, interval) + + const cached = await cache.match(cacheKey) + + let res: Response + + if (!cached) { + res = await rateLimiter.fetch( + new Request(fakeDomain, { + method: "POST", + body: JSON.stringify({ + scope: endpoint, + key, + limit, + interval, + }), + }) + ) + } else { + res = cached + } + + const body = await res.json<{ + blocked: boolean + remaining: number + expires: string + }>() + + const secondsExpires = dayjs(body.expires).unix() - dayjs().unix() + + setRateLimitHeaders( + ctx, + secondsExpires, + limit, + body.remaining, + interval + ) + + if (body.blocked) { + if (!cached) { + ctx.executionCtx.waitUntil(cache.put(cacheKey, res.clone())) + } + + return ctx.json( + { + success: false, + message: + "Rate limit exceeded, contact marcel@dromzeh.dev or reach out to @dromzeh on discord if you're using this API in production and need a higher rate limit.", + }, + 429 + ) + } + await next() + } +} diff --git a/src/v2/middleware/ratelimit/ratelimit.do.ts b/src/v2/middleware/ratelimit/ratelimit.do.ts new file mode 100644 index 0000000..6e73bdd --- /dev/null +++ b/src/v2/middleware/ratelimit/ratelimit.do.ts @@ -0,0 +1,180 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import dayjs from "dayjs" +import { Context } from "hono" +import { z, ZodError } from "zod" +import { generateErrorMessage, ErrorMessageOptions } from "zod-error" + +interface Config { + scope: string + key: string + limit: number + interval: number +} + +const configValidation = z.object({ + scope: z.string(), + key: z.string(), + limit: z.number().int().positive(), + interval: z.number().int().positive(), +}) + +const zodErrorOptions: ErrorMessageOptions = { + transform: ({ errorMessage, index }) => + `Error #${index + 1}: ${errorMessage}`, +} + +export class RateLimiter { + state: DurableObjectState + env: Bindings + app: OpenAPIHono = new OpenAPIHono() + + constructor(state: DurableObjectState, env: Bindings) { + this.state = state + this.env = env + + this.app.post("/", async (ctx) => { + await this.setAlarm() + + let config + + try { + config = await this.getConfig(ctx) + } catch (err: unknown) { + let errorMessage + if (err instanceof ZodError) { + errorMessage = generateErrorMessage( + err.issues, + zodErrorOptions + ) + } + return ctx.json( + { + statusCode: 400, + error: errorMessage, + }, + 400 + ) + } + const rate = await this.calculateRate(config) + const blocked = this.isRateLimited(rate, config.limit) + const headers = this.getHeaders(blocked, config) + const remaining = blocked ? 0 : Math.floor(config.limit - rate - 1) + + const remainingHeader = remaining >= 0 ? remaining : 0 + return ctx.json( + { + blocked, + remaining: remainingHeader, + expires: headers.expires, + }, + 200, + headers + ) + }) + } + + async alarm() { + const values = await this.state.storage.list() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const [key, _value] of values) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_scope, _key, _limit, interval, timestamp] = key.split("|") + const currentWindow = Math.floor( + this.nowUnix() / parseInt(interval) + ) + + const timestampLessThan = currentWindow - 2 + + if (parseInt(timestamp) < timestampLessThan) { + await this.state.storage.delete(key) + } + } + } + + async setAlarm() { + const alarm = await this.state.storage.getAlarm() + if (!alarm) { + this.state.storage.setAlarm(dayjs().add(6, "hours").toDate()) + } + } + + async getConfig(c: Context) { + const body = await c.req.json() + const config = configValidation.parse(body) + return config + } + + async incrementRequestCount(key: string) { + const currentRequestCount = await this.getRequestCount(key) + await this.state.storage.put(key, currentRequestCount + 1) + } + + async getRequestCount(key: string): Promise { + return parseInt((await this.state.storage.get(key)) as string) || 0 + } + + nowUnix() { + return dayjs().unix() + } + + async calculateRate(config: Config) { + const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}` + + const currentWindow = Math.floor(this.nowUnix() / config.interval) + const distanceFromLastWindow = this.nowUnix() % config.interval + + const currentKey = `${keyPrefix}|${currentWindow}` + const previousKey = `${keyPrefix}|${currentWindow - 1}` + + const currentCount = await this.getRequestCount(currentKey) + const previousCount = (await this.getRequestCount(previousKey)) || 0 + + const rate = + (previousCount * (config.interval - distanceFromLastWindow)) / + config.interval + + currentCount + + if (!this.isRateLimited(rate, config.limit)) { + await this.incrementRequestCount(currentKey) + } + + return rate + } + + isRateLimited(rate: number, limit: number) { + return rate >= limit + } + + getHeaders(blocked: boolean, config: Config) { + const expires = this.expirySeconds(config) + const retryAfter = this.retryAfter(expires) + + const headers: { expires: string; "cache-control"?: string } = { + expires: retryAfter.toString(), + } + + if (!blocked) { + return headers + } + + headers["cache-control"] = + `public, max-age=${expires}, s-maxage=${expires}, must-revalidate` + return headers + } + + expirySeconds(config: Config) { + const currentWindowStart = Math.floor(this.nowUnix() / config.interval) + const currentWindowEnd = currentWindowStart + 1 + const secondsRemaining = + currentWindowEnd * config.interval - this.nowUnix() + return secondsRemaining + } + + retryAfter(expires: number) { + return dayjs().add(expires, "seconds").toString() + } + + async fetch(request: Request): Promise { + return this.app.fetch(request) + } +} diff --git a/src/v2/routes/asset/comment-on-asset.ts b/src/v2/routes/asset/comment-on-asset.ts new file mode 100644 index 0000000..d3319de --- /dev/null +++ b/src/v2/routes/asset/comment-on-asset.ts @@ -0,0 +1,137 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { getConnection } from "@/v2/db/turso" +import { asset, assetComments } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const requestBodySchema = z.object({ + comment: z.string().min(3).max(128).openapi({ + description: "The comment to post.", + example: "This is a comment.", + }), +}) + +const pathSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the asset/comment to comment on.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/comment/{id}/comment", + method: "post", + summary: "Post comment or reply.", + description: "Accepts Asset IDs or Comment IDs.", + tags: ["Asset"], + request: { + params: pathSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns true if the comment was posted.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CommentRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { id } = ctx.req.valid("param") + const { comment } = ctx.req.valid("json") + + const isAsset = id.length === 15 + const isComment = id.length === 20 + + const { drizzle } = await getConnection(ctx.env) + + if (isAsset) { + const validAsset = await drizzle + .select({ + id: asset.id, + }) + .from(asset) + .where(eq(asset.id, id)) + + if (!validAsset) { + return ctx.json( + { + success: false, + message: "Invalid asset ID", + }, + 400 + ) + } + } + + if (isComment) { + const validComment = await drizzle + .select({ + id: assetComments.id, + }) + .from(assetComments) + .where(eq(assetComments.id, id)) + + if (!validComment) { + return ctx.json( + { + success: false, + message: "Invalid comment ID", + }, + 400 + ) + } + } + + await drizzle.insert(assetComments).values({ + assetId: isAsset ? id : null, + parentCommentId: isComment ? id : null, + comment: comment, + commentedById: user.id, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/delete-asset.ts b/src/v2/routes/asset/delete-asset.ts new file mode 100644 index 0000000..8b21405 --- /dev/null +++ b/src/v2/routes/asset/delete-asset.ts @@ -0,0 +1,80 @@ +import { type AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { eq } from "drizzle-orm" +import { asset } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the asset to delete.", + example: "asset_id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/delete", + method: "delete", + summary: "Delete an asset", + description: + "Delete an asset from their ID. Must be the owner of the asset or an admin.", + tags: ["Asset"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the asset was deleted.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteAssetByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [existingAsset] = await drizzle + .select({ id: asset.id }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!existingAsset) { + return ctx.json( + { + success: true, + message: "Asset not found", + }, + 400 + ) + } + + await drizzle.delete(asset).where(eq(asset.id, assetId)) + // await ctx.env.FILES_BUCKET.delete(asset.url) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/download-asset.ts b/src/v2/routes/asset/download-asset.ts new file mode 100644 index 0000000..8f58894 --- /dev/null +++ b/src/v2/routes/asset/download-asset.ts @@ -0,0 +1,85 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { asset } from "@/v2/db/schema" +import { eq, sql } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the asset to retrieve.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + downloadUrl: z.string(), +}) + +const openRoute = createRoute({ + path: "/{id}/download", + method: "get", + summary: "Download an asset", + description: "Download an asset by their ID.", + tags: ["Asset"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Asset downloaded successfully.", + response: { + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DownloadAssetRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundAsset] = await drizzle + .select({ + url: asset.url, + }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!foundAsset) { + return ctx.json( + { + success: false, + message: "Asset not found", + }, + 404 + ) + } + + await drizzle + .update(asset) + .set({ + viewCount: sql`${asset.downloadCount} + 1`, + }) + .where(eq(asset.id, assetId)) + + return ctx.json({ + success: true, + downloadUrl: foundAsset.url, + }) + }) +} diff --git a/src/v2/routes/asset/get-asset-comments.ts b/src/v2/routes/asset/get-asset-comments.ts new file mode 100644 index 0000000..a7cd5be --- /dev/null +++ b/src/v2/routes/asset/get-asset-comments.ts @@ -0,0 +1,133 @@ +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AppHandler } from "../handler" +import { asset, assetComments, assetCommentsLikes } from "@/v2/db/schema" +import { selectAssetCommentsSchema } from "@/v2/db/schema" +import { sql, eq, desc } from "drizzle-orm" + +const pathSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the asset to retrieve.", + required: true, + }, + }), +}) + +const querySchema = z.object({ + offset: z + .string() + .optional() + .openapi({ + param: { + name: "offset", + in: "query", + description: "The offset to start from.", + required: false, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + comments: z.array( + selectAssetCommentsSchema + .pick({ + id: true, + parentCommentId: true, + commentedById: true, + comment: true, + createdAt: true, + }) + .extend({ + hasReplies: z.boolean(), + likes: z.number(), + }) + ), +}) + +const openRoute = createRoute({ + path: "/{id}/comments", + method: "get", + summary: "Get an asset's comments", + description: "Get an asset's comments.", + tags: ["Asset"], + request: { + params: pathSchema, + query: querySchema, + }, + responses: { + 200: { + description: "Array of your asset comments.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ViewAssetCommentsRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + const offset = parseInt(ctx.req.valid("query").offset) || 0 + + const { drizzle } = await getConnection(ctx.env) + + const [assetAllowsComments] = await drizzle + .select({ + allowComments: asset.allowComments, + }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (assetAllowsComments.allowComments) { + return ctx.json( + { + success: false, + message: "Comments are locked for this asset.", + }, + 403 + ) + } + + const comments = await drizzle + .select({ + id: assetComments.id, + parentCommentId: assetComments.parentCommentId, + commentedById: assetComments.commentedById, + comment: assetComments.comment, + createdAt: assetComments.createdAt, + hasReplies: sql`EXISTS (SELECT 1 FROM assetComments AS ac WHERE ac.parent_comment_id = ${assetComments.id})`, + likes: sql`COUNT(${assetCommentsLikes.commentId})`, + }) + .from(assetComments) + .where(eq(assetComments.assetId, assetId)) + .leftJoin( + assetCommentsLikes, + eq(assetComments.id, assetCommentsLikes.commentId) + ) + .groupBy(assetComments.id) + .offset(offset) + .limit(10) + .orderBy(desc(assetComments.createdAt)) + + return ctx.json( + { + success: true, + comments: comments.map((c) => ({ + ...c, + hasReplies: !!c.hasReplies, + })), + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/get-asset.ts b/src/v2/routes/asset/get-asset.ts new file mode 100644 index 0000000..814bcb1 --- /dev/null +++ b/src/v2/routes/asset/get-asset.ts @@ -0,0 +1,191 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { asset, assetLikes } from "@/v2/db/schema" +import { eq, sql } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { + selectAssetCategorySchema, + selectGameSchema, + selectAssetSchema, + selectAssetTagAssetSchema, + selectAssetTagSchema, + selectUserSchema, +} from "@/v2/db/schema" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the asset to retrieve.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + // mmm nested schemas + asset: selectAssetSchema + .pick({ + id: true, + name: true, + extension: true, + url: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }) + .extend({ + assetTagAsset: z.array( + selectAssetTagAssetSchema.pick({}).extend({ + assetTag: selectAssetTagSchema.pick({ + id: true, + formattedName: true, + }), + }) + ), + authUser: selectUserSchema.pick({ + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + plan: true, + role: true, + }), + game: selectGameSchema.pick({ + id: true, + formattedName: true, + }), + assetCategory: selectAssetCategorySchema.pick({ + id: true, + formattedName: true, + }), + }), + assetLikes: z.number(), +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get an asset", + description: "Get an asset by their ID.", + tags: ["Asset"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "The found asset & similar assets are returned.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetAssetByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const foundAsset = await drizzle.query.asset.findFirst({ + columns: { + id: true, + name: true, + extension: true, + url: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }, + where: (asset, { eq }) => eq(asset.id, assetId), + with: { + assetTagAsset: { + columns: { + assetTagId: false, + assetId: false, + }, + with: { + assetTag: { + columns: { + id: true, + formattedName: true, + }, + }, + }, + }, + authUser: { + columns: { + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + plan: true, + role: true, + }, + }, + game: { + columns: { + id: true, + formattedName: true, + }, + }, + assetCategory: { + columns: { + id: true, + formattedName: true, + }, + }, + }, + }) + + const [totalAssetLikes] = await drizzle + .select({ + likeCount: sql`COUNT(${assetLikes.assetId})`, + }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!foundAsset) { + return ctx.json( + { + success: false, + message: "Asset not found", + }, + 400 + ) + } + + await drizzle + .update(asset) + .set({ + viewCount: sql`${asset.viewCount} + 1`, + }) + .where(eq(asset.id, assetId)) + + return ctx.json( + { + success: true, + asset: foundAsset, + assetLikes: totalAssetLikes.likeCount, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/get-comment-replies.ts b/src/v2/routes/asset/get-comment-replies.ts new file mode 100644 index 0000000..577f583 --- /dev/null +++ b/src/v2/routes/asset/get-comment-replies.ts @@ -0,0 +1,115 @@ +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AppHandler } from "../handler" +import { assetComments, assetCommentsLikes } from "@/v2/db/schema" +import { selectAssetCommentsSchema } from "@/v2/db/schema" +import { sql, eq, desc } from "drizzle-orm" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the comment to check replies for.", + required: true, + }, + }), +}) + +const querySchema = z.object({ + offset: z + .string() + .optional() + .openapi({ + param: { + name: "offset", + in: "query", + description: "The offset to start from.", + required: false, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + replies: z.array( + selectAssetCommentsSchema + .pick({ + id: true, + parentCommentId: true, + commentedById: true, + comment: true, + createdAt: true, + }) + .extend({ + hasReplies: z.boolean(), + likes: z.number(), + }) + ), +}) + +const openRoute = createRoute({ + path: "/comment/{id}/replies", + method: "get", + summary: "Get a comment's replies", + description: "Get a comment's replies.", + tags: ["Asset"], + request: { + params: paramsSchema, + query: querySchema, + }, + responses: { + 200: { + description: "Array of replies to a comment.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetCommentsRepliesRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const commentId = ctx.req.valid("param").id + const offset = parseInt(ctx.req.valid("query").offset) || 0 + + const { drizzle } = await getConnection(ctx.env) + + const replies = await drizzle + .select({ + id: assetComments.id, + parentCommentId: assetComments.parentCommentId, + commentedById: assetComments.commentedById, + comment: assetComments.comment, + createdAt: assetComments.createdAt, + hasReplies: sql`EXISTS (SELECT 1 FROM assetComments AS ac WHERE ac.parent_comment_id = ${assetComments.id})`, + likes: sql`COUNT(${assetCommentsLikes.commentId})`, + }) + .from(assetComments) + .where(eq(assetComments.parentCommentId, commentId)) + .leftJoin( + assetCommentsLikes, + eq(assetComments.id, assetCommentsLikes.commentId) + ) + .groupBy(assetComments.id) + .offset(offset) + .limit(10) + .orderBy(desc(assetComments.createdAt)) + + return ctx.json( + { + success: true, + replies: replies.map((reply) => ({ + ...reply, + hasReplies: !!reply.hasReplies, + })), + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/get-users-asset-likes.ts b/src/v2/routes/asset/get-users-asset-likes.ts new file mode 100644 index 0000000..889060c --- /dev/null +++ b/src/v2/routes/asset/get-users-asset-likes.ts @@ -0,0 +1,89 @@ +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { + selectAssetSchema, + selectAssetTagAssetSchema, + selectAssetTagSchema, + selectAssetLikesSchema, +} from "@/v2/db/schema" +import { AppHandler } from "../handler" + +const responseSchema = z.object({ + success: z.literal(true), + likes: z.array( + selectAssetLikesSchema.extend({ + asset: selectAssetSchema.extend({ + assetTagAsset: z.array( + selectAssetTagAssetSchema.extend({ + assetTag: selectAssetTagSchema, + }) + ), + }), + }) + ), +}) + +const openRoute = createRoute({ + path: "/likes", + method: "get", + summary: "Your liked assets", + description: "List of all your liked assets.", + tags: ["Asset"], + responses: { + 200: { + description: "Array of your liked assets.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetAssetLikesRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const likes = await drizzle.query.assetLikes.findMany({ + where: (assetLikes, { eq }) => eq(assetLikes.likedById, user.id), + with: { + asset: { + with: { + assetTagAsset: { + with: { + assetTag: true, + }, + }, + }, + }, + }, + offset: 0, + }) + + return ctx.json( + { + success: true, + likes, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/handler.ts b/src/v2/routes/asset/handler.ts new file mode 100644 index 0000000..1787f5a --- /dev/null +++ b/src/v2/routes/asset/handler.ts @@ -0,0 +1,34 @@ +import { OpenAPIHono } from "@hono/zod-openapi" + +import { GetAssetByIdRoute } from "./get-asset" +import { LikeAssetByIdRoute } from "./like-asset" +import { UnlikeAssetByIdRoute } from "./unlike-asset" +import { AssetSearchAllFilterRoute } from "./search-assets" +import { GetAssetLikesRoute } from "./get-users-asset-likes" +import { ModifyAssetRoute } from "./modify-asset" +import { UploadAssetRoute } from "./upload-asset" +import { DeleteAssetByIdRoute } from "./delete-asset" +import { DownloadAssetRoute } from "./download-asset" +import { GetCommentsRepliesRoute } from "./get-comment-replies" + +import { ViewAssetCommentsRoute } from "./get-asset-comments" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AssetSearchAllFilterRoute(handler) +UploadAssetRoute(handler) + +GetAssetByIdRoute(handler) +DownloadAssetRoute(handler) +ModifyAssetRoute(handler) +DeleteAssetByIdRoute(handler) + +LikeAssetByIdRoute(handler) +UnlikeAssetByIdRoute(handler) + +GetAssetLikesRoute(handler) + +ViewAssetCommentsRoute(handler) +GetCommentsRepliesRoute(handler) + +export default handler diff --git a/src/v2/routes/asset/like-asset.ts b/src/v2/routes/asset/like-asset.ts new file mode 100644 index 0000000..1962dd1 --- /dev/null +++ b/src/v2/routes/asset/like-asset.ts @@ -0,0 +1,116 @@ +import { type AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { asset, assetLikes } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the asset to like.", + example: "asset_id", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/like", + method: "post", + summary: "Like an asset", + description: "Like an asset from their ID.", + tags: ["Asset"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the asset was liked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const LikeAssetByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [existingAsset] = await drizzle + .select() + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!existingAsset) { + return ctx.json( + { + success: true, + message: "Asset not found", + }, + 400 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [assetLikeStatus] = await drizzle + .select({ assetId: assetLikes.assetId }) + .from(assetLikes) + .where( + and( + eq(assetLikes.assetId, assetId), + eq(assetLikes.likedById, user.id) + ) + ) + .limit(1) + + if (assetLikeStatus) { + return ctx.json( + { + success: false, + message: "Asset is already liked", + }, + 400 + ) + } + + await drizzle.insert(assetLikes).values({ + assetId: assetId, + likedById: user.id, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/like-comment.ts b/src/v2/routes/asset/like-comment.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/routes/asset/modify-asset.ts b/src/v2/routes/asset/modify-asset.ts new file mode 100644 index 0000000..8ec962e --- /dev/null +++ b/src/v2/routes/asset/modify-asset.ts @@ -0,0 +1,189 @@ +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { asset, assetTag, assetTagAsset } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { SplitQueryByCommas } from "@/v2/lib/helpers/split-query-by-commas" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AppHandler } from "../handler" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the asset to modify.", + example: "asset_id", + in: "path", + required: true, + }, + }), +}) + +const requestBodySchema = z.object({ + name: z + .string() + .min(3) + .max(32) + .openapi({ + description: "The name of the asset.", + example: "keqing-nobg.png", + }) + .optional(), + tags: z + .string() + .openapi({ + description: "Comma seperated list of tags for the asset.", + example: "official,1.0", + }) + .optional(), + assetCategoryId: z + .string() + .openapi({ + description: "The asset category ID for the asset.", + example: "splash-art", + }) + .optional(), + gameId: z + .string() + .openapi({ + description: "The game ID for the asset.", + example: "genshin-impact", + }) + .optional(), + allowComments: z.string().min(0).max(1).optional().openapi({ + description: "If comments are allowed on the asset. 1 = Yes, 0 = No.", + example: "1", + }), +}) + +const modifyAssetResponseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/modify", + method: "patch", + summary: "Modify an asset", + description: "Modify an existing asset.", + tags: ["Asset"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the asset's new attributes", + content: { + "application/json": { + schema: modifyAssetResponseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ModifyAssetRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { name, tags, assetCategoryId, gameId, allowComments } = + ctx.req.valid("json") + const assetId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { drizzle } = getConnection(ctx.env) + + const [assetUser] = await drizzle + .select({ + uploadedById: asset.uploadedById, + }) + .from(asset) + .where(eq(asset.id, assetId)) + + if (assetUser.uploadedById !== user.id || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await drizzle + .update(asset) + .set({ + name, + assetCategoryId, + gameId, + allowComments: Boolean(allowComments), + }) + .where(eq(asset.id, assetId)) + .returning() + + const newTags = SplitQueryByCommas(tags) ?? [] + + const oldTags = await drizzle + .select({ + assetTagId: assetTag.id, + }) + .from(assetTagAsset) + .innerJoin(assetTag, eq(assetTag.id, assetTagAsset.assetTagId)) + .where(eq(assetTagAsset.assetId, assetId)) + + const oldTagIds = oldTags.map((t) => t.assetTagId) + const tagsToRemove = oldTagIds.filter((t) => !newTags.includes(t)) + const tagsToAdd = newTags.filter((t) => !oldTagIds.includes(t)) + + const tagBatchQueries = [ + ...tagsToRemove.map((tagId) => + drizzle + .delete(assetTagAsset) + .where( + and( + eq(assetTagAsset.assetId, assetId), + eq(assetTagAsset.assetTagId, tagId) + ) + ) + ), + ...tagsToAdd.map((tag) => + drizzle.insert(assetTagAsset).values({ + assetId: assetId, + assetTagId: tag, + }) + ), + ] + + // https://github.com/drizzle-team/drizzle-orm/issues/1301 + type TagBatchQuery = (typeof tagBatchQueries)[number] + + await drizzle.batch( + tagBatchQueries as [TagBatchQuery, ...TagBatchQuery[]] + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/search-assets.ts b/src/v2/routes/asset/search-assets.ts new file mode 100644 index 0000000..a43f611 --- /dev/null +++ b/src/v2/routes/asset/search-assets.ts @@ -0,0 +1,230 @@ +import { getConnection } from "@/v2/db/turso" +import { SplitQueryByCommas } from "@/v2/lib/helpers/split-query-by-commas" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { + selectAssetSchema, + selectAssetTagAssetSchema, + selectAssetTagSchema, +} from "@/v2/db/schema" +import { type AppHandler } from "../handler" + +const responseSchema = z.object({ + success: z.literal(true), + assets: z.array( + selectAssetSchema + .pick({ + id: true, + name: true, + extension: true, + url: true, + assetCategoryId: true, + gameId: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }) + .extend({ + assetTagAsset: z.array( + selectAssetTagAssetSchema.pick({}).extend({ + assetTag: selectAssetTagSchema.pick({ + id: true, + formattedName: true, + }), + }) + ), + }) + ), +}) + +const querySchema = z + .object({ + name: z.string().openapi({ + param: { + description: + "The name of the asset(s) to retrieve. Doesn't have to be exact, uses 'like' operator to search.", + name: "name", + in: "query", + example: "keqing", + required: false, + }, + }), + game: z.string().openapi({ + param: { + description: + "The game id(s) of the asset(s) to retrieve. Comma seperated.", + name: "game", + in: "query", + example: "genshin-impact,honkai-impact-3rd", + required: false, + }, + }), + category: z.string().openapi({ + param: { + description: + "The category id(s) of the asset(s) to retrieve. Comma seperated.", + name: "category", + in: "query", + example: "character-sheets,splash-art", + required: false, + }, + }), + tags: z.string().openapi({ + param: { + description: "The tag id(s) of the asset(s) to retrieve.", + name: "tags", + in: "query", + example: "official,fanmade", + required: false, + }, + }), + uploader: z.string().openapi({ + param: { + description: + "The uploader usernames(s) of the asset(s) to retrieve. Comma seperated.", + name: "uploader", + in: "query", + example: "user1,user2", + required: false, + }, + }), + offset: z.string().openapi({ + param: { + description: "The offset for the asset(s) to retrieve.", + name: "offset", + example: "0", + in: "query", + required: false, + }, + }), + limit: z.string().openapi({ + param: { + description: "The limit for the asset(s) to retrieve.", + name: "limit", + example: "25", + in: "query", + required: false, + }, + }), + }) + .partial() + +export type assetSearchAllFilter = z.infer + +const openRoute = createRoute({ + path: "/search", + method: "get", + summary: "Search for assets", + description: "Filter all assets", + tags: ["Asset"], + request: { + query: querySchema, + }, + responses: { + 200: { + description: "Found assets", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AssetSearchAllFilterRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { drizzle } = await getConnection(ctx.env) + + const { name, game, category, tags, offset, limit, uploader } = + ctx.req.valid("query") + + const gameList = game ? SplitQueryByCommas(game.toLowerCase()) : null + const categoryList = category + ? SplitQueryByCommas(category.toLowerCase()) + : null + const searchQuery = name ?? null + const tagList = tags ? SplitQueryByCommas(tags.toLowerCase()) : null + + // is this bad for performance? probably + const assets = await drizzle.query.asset.findMany({ + where: (asset, { and, or, like, eq, sql }) => + and( + tagList && tagList.length > 0 + ? or( + ...tagList.map( + (t) => + sql`EXISTS (SELECT 1 FROM assetTagAsset WHERE assetTagAsset.asset_id = ${asset.id} AND assetTagAsset.asset_tag_id = ${t})` + ) + ) + : undefined, + searchQuery + ? like(asset.name, `%${searchQuery}%`) + : undefined, + gameList + ? or(...gameList.map((game) => eq(asset.gameId, game))) + : undefined, + categoryList + ? or( + ...categoryList.map((category) => + eq(asset.assetCategoryId, category) + ) + ) + : undefined, + uploader + ? or( + ...SplitQueryByCommas(uploader).map((uploader) => + eq(asset.uploadedByName, uploader) + ) + ) + : undefined, + eq(asset.status, "approved") + ), + limit: limit ? parseInt(limit) : 50, + offset: offset ? parseInt(offset) : 0, + columns: { + id: true, + name: true, + extension: true, + url: true, + assetCategoryId: true, + gameId: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }, + with: { + assetTagAsset: { + columns: { + assetTagId: false, + assetId: false, + }, + with: { + assetTag: { + columns: { + id: true, + formattedName: true, + }, + }, + }, + }, + }, + }) + + return ctx.json( + { + success: true, + assets, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/unlike-asset.ts b/src/v2/routes/asset/unlike-asset.ts new file mode 100644 index 0000000..4c5a81a --- /dev/null +++ b/src/v2/routes/asset/unlike-asset.ts @@ -0,0 +1,120 @@ +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { asset, assetLikes } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import type { AppHandler } from "../handler" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the asset to unlike.", + example: "asset_id", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/unlike", + method: "post", + summary: "Unlike an asset", + description: "Unlike an asset from their ID.", + tags: ["Asset"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the asset was unliked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UnlikeAssetByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [existingAsset] = await drizzle + .select() + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!existingAsset) { + return ctx.json( + { + success: true, + message: "Asset not found", + }, + 400 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [assetLikeStatus] = await drizzle + .select({ assetId: assetLikes.assetId }) + .from(assetLikes) + .where( + and( + eq(assetLikes.assetId, assetId), + eq(assetLikes.likedById, user.id) + ) + ) + .limit(1) + + if (!assetLikeStatus) { + return ctx.json( + { + success: false, + message: "You have not liked this asset", + }, + 400 + ) + } + + await drizzle + .delete(assetLikes) + .where( + and( + eq(assetLikes.assetId, assetId), + eq(assetLikes.likedById, user.id) + ) + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/asset/unlike-comment.ts b/src/v2/routes/asset/unlike-comment.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/routes/asset/upload-asset.ts b/src/v2/routes/asset/upload-asset.ts new file mode 100644 index 0000000..cd6c0e2 --- /dev/null +++ b/src/v2/routes/asset/upload-asset.ts @@ -0,0 +1,201 @@ +import type { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { SplitQueryByCommas } from "@/v2/lib/helpers/split-query-by-commas" +import { assetTagAsset } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { generateID } from "@/v2/lib/oslo" +import { CheckLabels } from "@/v2/lib/helpers/check-image-tags" + +const AcceptedImageType = "image/png" +const MaxFileSize = 5 * 1024 * 1024 + +const requestBodySchema = z.object({ + asset: z + .any() + .openapi({ + description: "The image of the asset to upload.", + example: "asset", + }) + .refine((files) => files?.length == 1, "An image is required.") + .refine( + (files) => files?.[0]?.size <= MaxFileSize, + `Max file size is 5MB)` + ) + .refine( + (files) => files?.[0]?.type === AcceptedImageType, + `Only ${AcceptedImageType} is accepted.` + ), + name: z.string().min(3).max(32).openapi({ + description: "The name of the asset.", + example: "keqing-nobg.png", + }), + tags: z + .string() + .openapi({ + description: "Comma seperated list of tags for the asset.", + example: "official,1.0", + }) + .optional(), + assetCategoryId: z.string().openapi({ + description: "The asset category ID for the asset.", + example: "splash-art", + }), + gameId: z.string().openapi({ + description: "The game ID for the asset.", + example: "genshin-impact", + }), + assetIsSuggestive: z + .string() + .min(1) + .max(1) + .openapi({ + description: + "If the asset contains suggestive content. 1 = Yes, 0 = No.", + example: "1", + }) + .transform((value) => parseInt(value)) + .refine((value) => value === 1 || value === 0), + allowComments: z.string().min(0).max(1).optional().openapi({ + description: "If comments are allowed on the asset. 1 = Yes, 0 = No.", + example: "1", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/upload", + method: "post", + summary: "Upload an asset", + description: "Upload a new asset.", + tags: ["Asset"], + request: { + body: { + content: { + "multipart/form-data": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "The uploaded asset.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UploadAssetRoute = (handler: AppHandler) => + handler.openapi(openRoute, async (ctx) => { + const { + asset, + name, + tags, + assetCategoryId, + gameId, + assetIsSuggestive, + } = ctx.req.valid("form") + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if ( + user.role != "creator" && + user.role != "contributor" && + user.role != "staff" + ) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const labels = await CheckLabels(ctx, asset) + + if (labels) { + return ctx.json( + { + success: false, + message: "Image contains potentially suggestive content.", + }, + 400 + ) + } + + const { drizzle } = getConnection(ctx.env) + + const randomId = generateID() + + const { key } = await ctx.env.FILES_BUCKET.put( + `/assets/${randomId}.png`, + asset + ) + + const createdAsset = await drizzle + .insert(asset) + .values({ + id: randomId, + name: name, + extension: "png", + gameId: gameId, + assetCategoryId: assetCategoryId, + url: key, + uploadedByName: user.username, + uploadedById: user.id, + status: "pending", + fileSize: 0, + width: 0, + height: 0, + allowComments: true, + assetIsSuggestive: Boolean(assetIsSuggestive), + }) + .returning() + + const tagsSplit = SplitQueryByCommas(tags) ?? [] + + if (tagsSplit.length > 0) { + const tagBatchQueries = tagsSplit.map((tag) => + drizzle.insert(assetTagAsset).values({ + assetId: createdAsset[0].id, + assetTagId: tag, + }) + ) + + type TagBatchQuery = (typeof tagBatchQueries)[number] + await drizzle.batch( + tagBatchQueries as [TagBatchQuery, ...TagBatchQuery[]] + ) + } + + return ctx.json( + { + success: true, + }, + 200 + ) + }) diff --git a/src/v2/routes/auth/account-create.ts b/src/v2/routes/auth/account-create.ts new file mode 100644 index 0000000..19f6fbd --- /dev/null +++ b/src/v2/routes/auth/account-create.ts @@ -0,0 +1,131 @@ +import { AppHandler } from "../handler" +import { UserAuthenticationManager } from "@/v2/lib/managers/auth/user-auth-manager" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const requestBodySchema = z.object({ + username: z.string().min(3).max(32).openapi({ + description: "The username of the user.", + example: "user", + }), + email: z.string().min(3).max(32).openapi({ + description: "The email of the user.", + example: "user@domain.com", + }), + password: z.string().min(8).max(64).openapi({ + description: "The password of the user.", + example: "password1234", + }), + passwordConfirmation: z.string().min(8).max(64).openapi({ + description: "The password confirmation of the user.", + example: "password1234", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/create", + method: "post", + summary: "Create a new account", + description: "Create a new user account with an email and password.", + tags: ["Auth"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns true.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UserCreateAccountRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const returnUnauth = true + + if (returnUnauth) { + return ctx.json( + { + success: true, + }, + 401 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (user) { + return ctx.json( + { + success: false, + message: "Already logged in", + }, + 401 + ) + } + + const { email, password, username } = ctx.req.valid("json") + + const userAuthManager = new UserAuthenticationManager(ctx) + + const existingUser = false + + if (existingUser) { + return ctx.json( + { + success: false, + message: "User already exists with that email", + }, + 400 + ) + } + + const newLoginCookie = await userAuthManager.createAccount( + { + email, + username, + }, + password + ) + + if (!newLoginCookie) { + return ctx.json( + { + success: false, + message: "Failed to create account", + }, + 500 + ) + } + + ctx.header("Set-Cookie", newLoginCookie.serialize(), { + append: true, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/account-login.ts b/src/v2/routes/auth/account-login.ts new file mode 100644 index 0000000..aad1385 --- /dev/null +++ b/src/v2/routes/auth/account-login.ts @@ -0,0 +1,97 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { UserAuthenticationManager } from "@/v2/lib/managers/auth/user-auth-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const requestBodySchema = z.object({ + email: z.string().min(3).max(32).openapi({ + description: "The email of the user.", + example: "user@domain.com", + }), + password: z.string().min(8).max(64).openapi({ + description: "The password of the user.", + example: "password1234", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/login", + method: "post", + summary: "Login", + description: "Login to a user with an email and password.", + tags: ["Auth"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns true.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UserLoginRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (user) { + return ctx.json( + { + success: false, + message: "Already logged in", + }, + 401 + ) + } + + const { email, password } = ctx.req.valid("json") + + const userAuthManager = new UserAuthenticationManager(ctx) + + const newLoginCookie = await userAuthManager.loginViaPassword( + email, + password + ) + + if (!newLoginCookie) { + return ctx.json( + { + success: false, + message: "Invalid credentials", + }, + 401 + ) + } + + ctx.header("Set-Cookie", newLoginCookie.serialize(), { + append: true, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/get-all-sessions.ts b/src/v2/routes/auth/get-all-sessions.ts new file mode 100644 index 0000000..087eba6 --- /dev/null +++ b/src/v2/routes/auth/get-all-sessions.ts @@ -0,0 +1,69 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { selectSessionSchema } from "@/v2/db/schema" +import { z } from "@hono/zod-openapi" + +const responseSchema = z.object({ + success: z.literal(true), + currentSessions: selectSessionSchema + .pick({ + id: true, + expiresAt: true, + }) + .array(), +}) + +const openRoute = createRoute({ + path: "/sessions", + method: "get", + summary: "Get all current sessions", + description: "Get all current sessions.", + tags: ["Auth"], + responses: { + 200: { + description: "All current sessions are returned", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UserAllCurrentSessionsRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const sessions = await authSessionManager.getAllSessions() + + return ctx.json( + { + success: true, + currentSessions: sessions.map((session) => { + return { + id: session.id, + expiresAt: session.expiresAt.toISOString(), + userAgent: session.userAgent, + } + }), + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/handler.ts b/src/v2/routes/auth/handler.ts new file mode 100644 index 0000000..7602300 --- /dev/null +++ b/src/v2/routes/auth/handler.ts @@ -0,0 +1,24 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { UserCreateAccountRoute } from "./account-create" +import { UserLoginRoute } from "./account-login" +import { UserAllCurrentSessionsRoute } from "./get-all-sessions" +import { LogoutCurrentSessionRoute } from "./logout-current-session" +import { ValidateSessionRoute } from "./validate-current-session" +import { InvalidateSessionRoute } from "./invalidate-session" +import { UploadAvatarRoute } from "./upload-avatar" +import { UploadBannerRoute } from "./upload-banner" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +UserCreateAccountRoute(handler) +UserLoginRoute(handler) + +ValidateSessionRoute(handler) +InvalidateSessionRoute(handler) +UserAllCurrentSessionsRoute(handler) +LogoutCurrentSessionRoute(handler) + +UploadAvatarRoute(handler) +UploadBannerRoute(handler) + +export default handler diff --git a/src/v2/routes/auth/invalidate-session.ts b/src/v2/routes/auth/invalidate-session.ts new file mode 100644 index 0000000..e46da7a --- /dev/null +++ b/src/v2/routes/auth/invalidate-session.ts @@ -0,0 +1,95 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +// import { deleteCookie } from "hono/cookie" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the session to invalidate.", + example: "session_id", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/invalidate/{id}", + method: "get", + summary: "Invalidate a session", + description: "Invalidate a session by its ID.", + tags: ["Auth"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Logout successful.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const InvalidateSessionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const sessionId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user, session } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (sessionId == session.id) { + return ctx.json( + { + success: false, + message: "Cannot invalidate the current session.", + }, + 400 + ) + } + + const sessions = await authSessionManager.getAllSessions() + + if (!sessions.find((s) => s.id === sessionId)) { + return ctx.json( + { + success: false, + message: "Session not found.", + }, + 400 + ) + } + + await authSessionManager.invalidateSessionById(sessionId) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/logout-current-session.ts b/src/v2/routes/auth/logout-current-session.ts new file mode 100644 index 0000000..5697ec9 --- /dev/null +++ b/src/v2/routes/auth/logout-current-session.ts @@ -0,0 +1,58 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { deleteCookie } from "hono/cookie" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/logout", + method: "get", + summary: "Logout", + description: "Logout current session.", + tags: ["Auth"], + responses: { + 200: { + description: "Logout successful.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const LogoutCurrentSessionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await authSessionManager.invalidateCurrentSession() + + deleteCookie(ctx, "user_auth_session") + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/upload-avatar.ts b/src/v2/routes/auth/upload-avatar.ts new file mode 100644 index 0000000..582f262 --- /dev/null +++ b/src/v2/routes/auth/upload-avatar.ts @@ -0,0 +1,117 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { CheckLabels } from "@/v2/lib/helpers/check-image-tags" +import { generateID } from "@/v2/lib/oslo" +import { getConnection } from "@/v2/db/turso" +import { authUser } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const requestBodySchema = z.object({ + avatar: z + .any() + .openapi({ + description: "The image of the avatar to upload.", + example: "avatar", + }) + .refine((files) => files?.length == 1, "An image is required.") + .refine( + (files) => files?.[0]?.size <= 5 * 1024 * 1024, + `Max file size is 5MB)` + ) + .refine( + (files) => files?.[0]?.type === "image/png", + `Only image/png is accepted.` + ), +}) + +const openRoute = createRoute({ + path: "/upload/avatar", + method: "post", + summary: "Upload an avatar", + description: "Upload a new avatar, png only.", + tags: ["Auth"], + request: { + body: { + content: { + "multipart/form-data": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "The uploaded avatar", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UploadAvatarRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { avatar } = ctx.req.valid("form") + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const labels = await CheckLabels(ctx, avatar) + + if (labels) { + return ctx.json( + { + success: false, + message: "Image contains potentially inappropriate content", + }, + 400 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + if (user.avatarUrl) { + await ctx.env.FILES_BUCKET.delete(user.avatarUrl) + } + + const { key } = await ctx.env.FILES_BUCKET.put( + `/avatars/${user.id}/${generateID(12)}.png`, + avatar.content + ) + + await drizzle + .update(authUser) + .set({ + avatarUrl: key, + }) + .where(eq(authUser.id, user.id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/upload-banner.ts b/src/v2/routes/auth/upload-banner.ts new file mode 100644 index 0000000..4542e04 --- /dev/null +++ b/src/v2/routes/auth/upload-banner.ts @@ -0,0 +1,117 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { CheckLabels } from "@/v2/lib/helpers/check-image-tags" +import { generateID } from "@/v2/lib/oslo" +import { getConnection } from "@/v2/db/turso" +import { authUser } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const requestBodySchema = z.object({ + banner: z + .any() + .openapi({ + description: "The image of the banner to upload.", + example: "banner", + }) + .refine((files) => files?.length == 1, "An image is required.") + .refine( + (files) => files?.[0]?.size <= 5 * 1024 * 1024, + `Max file size is 5MB)` + ) + .refine( + (files) => files?.[0]?.type === "image/png", + `Only image/png is accepted.` + ), +}) + +const openRoute = createRoute({ + path: "/upload/banner", + method: "post", + summary: "Upload a banner", + description: "Upload a new banner, png only, supporter only.", + tags: ["Auth"], + request: { + body: { + content: { + "multipart/form-data": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "The uploaded banner", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UploadBannerRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { banner } = ctx.req.valid("form") + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.plan !== "supporter") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const labels = await CheckLabels(ctx, banner) + + if (labels) { + return ctx.json( + { + success: false, + message: "Image contains potentially inappropriate content", + }, + 400 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + if (user.bannerUrl) { + await ctx.env.FILES_BUCKET.delete(user.bannerUrl) + } + + const { key } = await ctx.env.FILES_BUCKET.put( + `/banners/${user.id}/${generateID(12)}.png`, + banner.content + ) + + await drizzle + .update(authUser) + .set({ + bannerUrl: key, + }) + .where(eq(authUser.id, user.id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/auth/validate-current-session.ts b/src/v2/routes/auth/validate-current-session.ts new file mode 100644 index 0000000..02d2816 --- /dev/null +++ b/src/v2/routes/auth/validate-current-session.ts @@ -0,0 +1,58 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectSessionSchema, selectUserSchema } from "@/v2/db/schema" + +const responseSchema = z.object({ + success: z.literal(true), + user: selectUserSchema, + session: selectSessionSchema, +}) + +const openRoute = createRoute({ + path: "/validate", + method: "get", + summary: "Validate current session", + description: "Validate current session.", + tags: ["Auth"], + responses: { + 200: { + description: "All user information is returned.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ValidateSessionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user, session } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + return ctx.json( + { + success: true, + user: user, + session: session, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/category/all-categories.ts b/src/v2/routes/category/all-categories.ts new file mode 100644 index 0000000..f032121 --- /dev/null +++ b/src/v2/routes/category/all-categories.ts @@ -0,0 +1,48 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { assetCategory } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { createRoute } from "@hono/zod-openapi" +import { z } from "@hono/zod-openapi" +import { selectAssetCategorySchema } from "@/v2/db/schema" + +const responseSchema = z.object({ + success: z.literal(true), + categories: selectAssetCategorySchema.array(), +}) + +const openRoute = createRoute({ + path: "/all", + method: "get", + summary: "Get all categories", + description: "Get all categories.", + tags: ["Category"], + responses: { + 200: { + description: "All categories.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AllCategoriesRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { drizzle } = await getConnection(ctx.env) + + const assetCategories = + (await drizzle.select().from(assetCategory)) ?? [] + + return ctx.json( + { + success: true, + categories: assetCategories, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/category/create-category.ts b/src/v2/routes/category/create-category.ts new file mode 100644 index 0000000..b55d25e --- /dev/null +++ b/src/v2/routes/category/create-category.ts @@ -0,0 +1,108 @@ +import { AppHandler } from "../handler" +import { assetCategory } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectAssetCategorySchema } from "@/v2/db/schema" + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The name of the asset category.", + example: "splash-art", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The formatted name of the category.", + example: "Splash Art", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + assetCategory: selectAssetCategorySchema, +}) + +const openRoute = createRoute({ + path: "/create", + method: "post", + summary: "Create a category", + description: "Create a new category.", + tags: ["Category"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the new category.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CreateCategoryRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName } = ctx.req.valid("json") + + const { drizzle } = getConnection(ctx.env) + + const [categoryExists] = await drizzle + .select({ name: assetCategory.name }) + .from(assetCategory) + .where(eq(assetCategory.name, name)) + + if (categoryExists) { + return ctx.json( + { + success: false, + message: "Category already exists", + }, + 400 + ) + } + + const [newCategory] = await drizzle + .insert(assetCategory) + .values({ + id: name, + name, + formattedName, + lastUpdated: new Date().toISOString(), + }) + .returning() + + return ctx.json( + { + success: true, + assetCategory: newCategory, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/category/delete-category.ts b/src/v2/routes/category/delete-category.ts new file mode 100644 index 0000000..567faf5 --- /dev/null +++ b/src/v2/routes/category/delete-category.ts @@ -0,0 +1,91 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { assetCategory } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the category to delete.", + example: "splash-art", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/delete", + method: "delete", + summary: "Delete a category", + description: "Delete a category.", + tags: ["Category"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Returns boolean indicating success.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteAssetCategoryRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundCategory] = await drizzle + .select({ id: assetCategory.id }) + .from(assetCategory) + .where(eq(assetCategory.id, id)) + + if (!foundCategory) { + return ctx.json( + { + success: false, + message: "Category not found", + }, + 404 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await drizzle.delete(assetCategory).where(eq(assetCategory.id, id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/category/get-category.ts b/src/v2/routes/category/get-category.ts new file mode 100644 index 0000000..3086605 --- /dev/null +++ b/src/v2/routes/category/get-category.ts @@ -0,0 +1,78 @@ +import { AppHandler } from "../handler" +import { createRoute } from "@hono/zod-openapi" +import { assetCategory } from "@/v2/db/schema" +import { getConnection } from "@/v2/db/turso" +import { eq } from "drizzle-orm" +import { z } from "@hono/zod-openapi" +import { selectAssetCategorySchema } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the category to retrieve.", + example: "splash-art", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + category: selectAssetCategorySchema, +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get a category", + description: "Get a category by their ID.", + tags: ["Category"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Category was found.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetCategoryByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundCategory] = await drizzle + .select() + .from(assetCategory) + .where(eq(assetCategory.id, id)) + + if (!foundCategory) { + return ctx.json( + { + success: false, + message: "Category not found", + }, + 400 + ) + } + + return ctx.json( + { + success: true, + category: foundCategory, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/category/handler.ts b/src/v2/routes/category/handler.ts new file mode 100644 index 0000000..909ff67 --- /dev/null +++ b/src/v2/routes/category/handler.ts @@ -0,0 +1,16 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { AllCategoriesRoute } from "./all-categories" +import { CreateCategoryRoute } from "./create-category" +import { GetCategoryByIdRoute } from "./get-category" +import { ModifyAssetCategoryRoute } from "./modify-category" +import { DeleteAssetCategoryRoute } from "./delete-category" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AllCategoriesRoute(handler) +GetCategoryByIdRoute(handler) +CreateCategoryRoute(handler) +ModifyAssetCategoryRoute(handler) +DeleteAssetCategoryRoute(handler) + +export default handler diff --git a/src/v2/routes/category/modify-category.ts b/src/v2/routes/category/modify-category.ts new file mode 100644 index 0000000..ceb9fab --- /dev/null +++ b/src/v2/routes/category/modify-category.ts @@ -0,0 +1,120 @@ +import { AppHandler } from "../handler" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { assetCategory, selectAssetCategorySchema } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + description: "The id of the category to modify.", + example: "splash-art", + in: "path", + required: true, + }, + }), +}) + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The new name of the category.", + example: "splash-art", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The new formatted name of the category.", + example: "Splash Art", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + assetCategory: selectAssetCategorySchema, +}) + +const openRoute = createRoute({ + path: "/{id}/modify", + method: "patch", + summary: "Modify a category", + description: "Modify an existing category.", + tags: ["Category"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the new category attributes", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ModifyAssetCategoryRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName } = ctx.req.valid("json") + const { id } = ctx.req.valid("param") + + const { drizzle } = getConnection(ctx.env) + + const [existingCategory] = await drizzle + .select({ id: assetCategory.id }) + .from(assetCategory) + .where(eq(assetCategory.id, id)) + + if (!existingCategory) { + return ctx.json( + { + success: false, + message: "Category not found", + }, + 404 + ) + } + + const [updatedCategory] = await drizzle + .update(assetCategory) + .set({ + name, + formattedName, + lastUpdated: new Date().toISOString(), + }) + .where(eq(assetCategory.id, id)) + .returning() + + return ctx.json( + { + success: true, + assetCategory: updatedCategory, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/add-asset.ts b/src/v2/routes/collection/add-asset.ts new file mode 100644 index 0000000..5e0b7da --- /dev/null +++ b/src/v2/routes/collection/add-asset.ts @@ -0,0 +1,190 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { + userCollection, + userCollectionCollaborators, + asset, + userCollectionAsset, +} from "@/v2/db/schema" +import { and, desc, eq, not } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to add asset to", + required: true, + }, + }), + assetId: z.string().openapi({ + param: { + name: "assetId", + in: "path", + description: "The ID of the asset to add.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/add/{assetId}", + method: "post", + summary: "Add asset to collection", + description: + "Add an asset to a collection, you must be the collection owner or a collaborator to add an asset.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: + "Returns true if the asset was added to the collection.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AddAssetToCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id, assetId } = ctx.req.valid("param") + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [existingCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + .limit(1) + + if (!existingCollection) { + return ctx.json( + { + success: false, + message: "Collection ID provided does not exist", + }, + 404 + ) + } + + const [existingAsset] = await drizzle + .select({ + id: asset.id, + }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!existingAsset) { + return ctx.json( + { + success: false, + message: "Asset ID provided does not exist", + }, + 404 + ) + } + + const [existingCollaborator] = await drizzle + .select({ + role: userCollectionCollaborators.role, + collaboratorId: userCollectionCollaborators.collaboratorId, + }) + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq(userCollectionCollaborators.collaboratorId, user.id), + not(eq(userCollectionCollaborators.role, "viewer")) + ) + ) + .limit(1) + + if (!existingCollaborator && existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + // check if asset is already in collection + + const [existingAssetInCollection] = await drizzle + .select({ + collectionId: userCollectionAsset.collectionId, + assetId: userCollectionAsset.assetId, + }) + .from(userCollectionAsset) + .where( + and( + eq(userCollectionAsset.collectionId, id), + eq(userCollectionAsset.assetId, assetId) + ) + ) + .limit(1) + + if (existingAssetInCollection) { + return ctx.json( + { + success: false, + message: "Asset already in collection", + }, + 400 + ) + } + + const [lastOrder] = await drizzle + .select({ + order: userCollectionAsset.order, + }) + .from(userCollectionAsset) + .where(eq(userCollectionAsset.collectionId, id)) + .orderBy(desc(userCollectionAsset.order)) + .limit(1) + + await drizzle.insert(userCollectionAsset).values({ + collectionId: id, + order: lastOrder ? lastOrder.order + 1 : 0, + assetId: assetId, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/add-contributor.ts b/src/v2/routes/collection/add-contributor.ts new file mode 100644 index 0000000..4e5b594 --- /dev/null +++ b/src/v2/routes/collection/add-contributor.ts @@ -0,0 +1,122 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { userCollection, userCollectionCollaborators } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to add contributor to", + required: true, + }, + }), + contributorId: z.string().openapi({ + param: { + name: "contributorId", + in: "path", + description: "The user ID of the contribitor to add.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/contributor/{contributorId}/add", + method: "post", + summary: "Add contributor to collection", + description: + "Add a contributor to a collection, you must be the collection owner to add a contributor.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: + "Returns true if the contributor was added to the collection.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AddContributorToCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id, contributorId } = ctx.req.valid("param") + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + const [existingCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + + if (!existingCollection || existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [contributor] = await drizzle + .select({ + collectionId: userCollectionCollaborators.collectionId, + collaboratorId: userCollectionCollaborators.collaboratorId, + }) + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq( + userCollectionCollaborators.collaboratorId, + contributorId + ) + ) + ) + .limit(1) + + if (contributor) { + return ctx.json( + { + success: false, + message: "Contributor already exists", + }, + 400 + ) + } + + await drizzle.insert(userCollectionCollaborators).values({ + collectionId: id, + collaboratorId: contributorId, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/create-collection.ts b/src/v2/routes/collection/create-collection.ts new file mode 100644 index 0000000..ab7457b --- /dev/null +++ b/src/v2/routes/collection/create-collection.ts @@ -0,0 +1,92 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { getConnection } from "@/v2/db/turso" +import { selectUserCollectionSchema, userCollection } from "@/v2/db/schema" +import { ColourType } from "@/v2/lib/colour" + +const requestBodySchema = z.object({ + name: z.string().min(1).max(32), + description: z.string().min(1).max(256), + isPublic: z.number().int().min(0).max(1), + accentColour: z.string().length(7).optional(), // hex +}) + +const responseSchema = z.object({ + success: z.literal(true), + collection: selectUserCollectionSchema, +}) + +const openRoute = createRoute({ + path: "/collection/create", + method: "post", + summary: "Create a new collection", + description: + "Create a new collection, accent colours available for supporters", + tags: ["Collection"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: + "Returns the collection + true if the collection was made.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CreateCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, description, isPublic, accentColour } = + ctx.req.valid("json") + + const { drizzle } = await getConnection(ctx.env) + + const [newCollection] = await drizzle + .insert(userCollection) + .values({ + name: name, + userId: user.id, + description: description, + isPublic: Boolean(isPublic), + accentColour: accentColour as ColourType, + }) + .returning() + + return ctx.json( + { + success: true, + collection: newCollection, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/delete-collection.ts b/src/v2/routes/collection/delete-collection.ts new file mode 100644 index 0000000..3490227 --- /dev/null +++ b/src/v2/routes/collection/delete-collection.ts @@ -0,0 +1,100 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { getConnection } from "@/v2/db/turso" +import { userCollection } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const pathSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to delete.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/collection/{id}/delete", + method: "delete", + summary: "Delete a collection", + description: + "Delete a collection. Only the owner of the collection can delete it.", + tags: ["Collection"], + request: { + params: pathSchema, + }, + responses: { + 200: { + description: + "Returns true if the collection was deleted successfully.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { id } = ctx.req.valid("param") + + const { drizzle } = await getConnection(ctx.env) + + const [existingCollection] = await drizzle.select().from(userCollection) + + if (!existingCollection) { + return ctx.json( + { + success: false, + message: "Collection not found.", + }, + 404 + ) + } + + if (existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await drizzle.delete(userCollection).where(eq(userCollection.id, id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/get-collection-assets.ts b/src/v2/routes/collection/get-collection-assets.ts new file mode 100644 index 0000000..2ad48de --- /dev/null +++ b/src/v2/routes/collection/get-collection-assets.ts @@ -0,0 +1,188 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { + userCollection, + userCollectionCollaborators, + selectUserCollectionAssetSchema, + selectAssetSchema, +} from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to retrieve.", + required: true, + }, + }), +}) + +const querySchema = z.object({ + offset: z + .string() + .optional() + .openapi({ + param: { + name: "offset", + in: "query", + description: "The offset to start from.", + required: false, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + assets: z.array( + selectUserCollectionAssetSchema + .pick({ order: true, dateAdded: true }) + .extend({ + asset: selectAssetSchema.pick({ + id: true, + name: true, + extension: true, + url: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }), + }) + ), +}) + +const openRoute = createRoute({ + path: "/{id}/assets", + method: "get", + summary: "Get a collection's assets", + description: + "Get a collection's assets by its ID. Returns 50 per request. If you do not have access to the collection (it is private/you do not have edit permission), it will not be returned.", + tags: ["Collection"], + request: { + params: paramsSchema, + query: querySchema, + }, + responses: { + 200: { + description: "Asset information", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetCollectionAssetsByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id } = ctx.req.valid("param") + const { offset } = ctx.req.valid("query") + + const { drizzle } = await getConnection(ctx.env) + + const [validCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + isPublic: userCollection.isPublic, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + + if (!validCollection) { + return ctx.json( + { + success: false, + message: "Collection not found", + }, + 404 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!validCollection.isPublic) { + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [collaborator] = await drizzle + .select() + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq(userCollectionCollaborators.collaboratorId, user.id) + ) + ) + + if (!collaborator && validCollection.userId != user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + } + + const collectionAssets = + await drizzle.query.userCollectionAsset.findMany({ + columns: { + collectionId: true, + order: true, + dateAdded: true, + }, + where: (userCollectionAsset) => + eq(userCollectionAsset.collectionId, id), + with: { + asset: { + columns: { + id: true, + name: true, + extension: true, + url: true, + viewCount: true, + downloadCount: true, + uploadedDate: true, + fileSize: true, + width: true, + height: true, + }, + }, + }, + offset: parseInt(offset) || 0, + limit: 50, + orderBy: (userCollectionAsset, { asc }) => [ + asc(userCollectionAsset.order), + ], + }) + + return ctx.json( + { + success: true, + assets: collectionAssets, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/get-collection.ts b/src/v2/routes/collection/get-collection.ts new file mode 100644 index 0000000..38a6ebc --- /dev/null +++ b/src/v2/routes/collection/get-collection.ts @@ -0,0 +1,165 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { + userCollection, + userCollectionCollaborators, + selectUserCollectionSchema, + selectUserSchema, +} from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to retrieve.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + collection: selectUserCollectionSchema + .pick({ + id: true, + name: true, + accentColour: true, + isPublic: true, + userId: true, + }) + .extend({ + authUser: selectUserSchema.pick({ + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + plan: true, + role: true, + }), + }), +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get a collection", + description: + "Get a collection by its ID. If you do not have access to the collection (it is private/you do not have edit permission), it will not be returned.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Basic information about the collection is returned.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetCollectionByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id } = ctx.req.valid("param") + + const { drizzle } = await getConnection(ctx.env) + + const [validCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + isPublic: userCollection.isPublic, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + + if (!validCollection) { + return ctx.json( + { + success: false, + message: "Collection not found", + }, + 404 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!validCollection.isPublic) { + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [collaborator] = await drizzle + .select() + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq(userCollectionCollaborators.collaboratorId, user.id) + ) + ) + + if (!collaborator && validCollection.userId != user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + } + + const collectionInfo = await drizzle.query.userCollection.findFirst({ + columns: { + id: true, + name: true, + accentColour: true, + isPublic: true, + userId: true, + }, + where: (collection, { eq }) => eq(collection.id, id), + with: { + authUser: { + columns: { + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + plan: true, + role: true, + }, + }, + }, + }) + + return ctx.json( + { + success: true, + collection: collectionInfo, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/handler.ts b/src/v2/routes/collection/handler.ts new file mode 100644 index 0000000..e8764eb --- /dev/null +++ b/src/v2/routes/collection/handler.ts @@ -0,0 +1,34 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { CreateCollectionRoute } from "./create-collection" +import { DeleteCollectionRoute } from "./delete-collection" +import { GetCollectionByIdRoute } from "./get-collection" +import { ModifyCollectionRoute } from "./modify-collection" +import { GetCollectionAssetsByIdRoute } from "./get-collection-assets" +import { UnlikeCollectionByIDRoute } from "./unlike-collection" +import { LikeCollectionByIdRoute } from "./like-collection" +import { AddAssetToCollectionRoute } from "./add-asset" +import { RemoveAssetFromCollectionRoute } from "./remove-asset" +import { AddContributorToCollectionRoute } from "./add-contributor" +import { RemoveContributorFromCollectionRoute } from "./remove-contributor" +import { UpdateContributorStatusRoute } from "./update-contributor-status" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +GetCollectionByIdRoute(handler) +GetCollectionAssetsByIdRoute(handler) + +ModifyCollectionRoute(handler) +CreateCollectionRoute(handler) +DeleteCollectionRoute(handler) + +AddAssetToCollectionRoute(handler) +RemoveAssetFromCollectionRoute(handler) +UpdateContributorStatusRoute(handler) + +AddContributorToCollectionRoute(handler) +RemoveContributorFromCollectionRoute(handler) + +LikeCollectionByIdRoute(handler) +UnlikeCollectionByIDRoute(handler) + +export default handler diff --git a/src/v2/routes/collection/like-collection.ts b/src/v2/routes/collection/like-collection.ts new file mode 100644 index 0000000..f0d7c09 --- /dev/null +++ b/src/v2/routes/collection/like-collection.ts @@ -0,0 +1,114 @@ +import { type AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { userCollection, userCollectionLikes } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the collection to like.", + example: "collection_id", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/like", + method: "post", + summary: "Like a collection", + description: "Like a collection from their ID.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the collection was liked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const LikeCollectionByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + const [existingCollection] = await drizzle + .select() + .from(userCollection) + .where(eq(userCollection.id, assetId)) + .limit(1) + + if ( + !existingCollection || + (!existingCollection.isPublic && + existingCollection.userId !== user.id) + ) { + // i don't know the best way to handle the logic for this, + // so i'm just rolling with completely disabling liking for private collections + // in the case that it is private and there's contributors, too bad i guess LOL + return ctx.json( + { + success: false, + message: "Collection not found", + }, + 404 + ) + } + + const [collectionLikeStatus] = await drizzle + .select() + .from(userCollectionLikes) + .where( + and( + eq(userCollectionLikes.collectionId, assetId), + eq(userCollectionLikes.likedById, user.id) + ) + ) + .limit(1) + + if (collectionLikeStatus) { + return ctx.json( + { + success: false, + message: "Collection already liked", + }, + 400 + ) + } + + await drizzle.insert(userCollectionLikes).values({ + collectionId: assetId, + likedById: user.id, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/modify-collection.ts b/src/v2/routes/collection/modify-collection.ts new file mode 100644 index 0000000..c00a1d0 --- /dev/null +++ b/src/v2/routes/collection/modify-collection.ts @@ -0,0 +1,141 @@ +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { selectUserCollectionSchema, userCollection } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AppHandler } from "../handler" +import { ColourType } from "@/v2/lib/colour" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the collection to modify.", + example: "collection_id", + in: "path", + required: true, + }, + }), +}) + +const requestBodySchema = z.object({ + name: z.string().min(1).max(32).optional(), + description: z.string().min(1).max(256).optional(), + isPublic: z.number().int().min(0).max(1).optional(), + accentColour: z.string().length(7).optional(), +}) + +const responseSchema = z.object({ + success: z.literal(true), + collection: selectUserCollectionSchema, +}) + +const openRoute = createRoute({ + path: "/{id}/modify", + method: "patch", + summary: "Modify a collection", + description: + "Modify an existing collection, you must be the owner of the collection to modify these details..", + tags: ["Collection"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the collection's new attributes", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ModifyCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { name, description, isPublic, accentColour } = + ctx.req.valid("json") + + const { id } = ctx.req.valid("param") + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [collection] = await drizzle + .select({ + id: userCollection.id, + isPublic: userCollection.isPublic, + userId: userCollection.userId, + }) + .from(userCollection) + .where( + and( + eq(userCollection.id, id), + eq(userCollection.userId, user.id) + ) + ) + + if (!collection || collection.isPublic) { + return ctx.json( + { + success: false, + message: "Collection not found", + }, + 404 + ) + } + + if (collection.userId !== user.id) { + return ctx.json( + { + success: false, + message: + "Unauthorized. You are not the owner of this collection.", + }, + 401 + ) + } + + const [updatedCollection] = await drizzle + .update(userCollection) + .set({ + name: name, + description: description, + isPublic: Boolean(isPublic), + accentColour: accentColour as ColourType, + }) + .where(eq(userCollection.id, id)) + .returning() + + return ctx.json( + { + success: true, + collection: updatedCollection, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/remove-asset.ts b/src/v2/routes/collection/remove-asset.ts new file mode 100644 index 0000000..befce58 --- /dev/null +++ b/src/v2/routes/collection/remove-asset.ts @@ -0,0 +1,184 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { + userCollection, + userCollectionCollaborators, + asset, + userCollectionAsset, +} from "@/v2/db/schema" +import { and, eq, not } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to remove asset from", + required: true, + }, + }), + assetId: z.string().openapi({ + param: { + name: "assetId", + in: "path", + description: "The ID of the asset to remove", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/remove/{assetId}", + method: "post", + summary: "Remove collection asset", + description: + "Remove an asset from a collection, you must be the collection owner or a collaborator to remove an asset.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: + "Returns true if the asset was removed to the collection.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const RemoveAssetFromCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id, assetId } = ctx.req.valid("param") + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [existingCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + .limit(1) + + if (!existingCollection) { + return ctx.json( + { + success: false, + message: "Collection ID provided does not exist", + }, + 404 + ) + } + + const [existingAsset] = await drizzle + .select({ + id: asset.id, + }) + .from(asset) + .where(eq(asset.id, assetId)) + .limit(1) + + if (!existingAsset) { + return ctx.json( + { + success: false, + message: "Asset ID provided does not exist", + }, + 404 + ) + } + + const [existingCollaborator] = await drizzle + .select({ + role: userCollectionCollaborators.role, + collaboratorId: userCollectionCollaborators.collaboratorId, + }) + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq(userCollectionCollaborators.collaboratorId, user.id), + not(eq(userCollectionCollaborators.role, "viewer")) + ) + ) + .limit(1) + + if (!existingCollaborator && existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + // check if asset is already in collection + + const [existingAssetInCollection] = await drizzle + .select({ + collectionId: userCollectionAsset.collectionId, + assetId: userCollectionAsset.assetId, + }) + .from(userCollectionAsset) + .where( + and( + eq(userCollectionAsset.collectionId, id), + eq(userCollectionAsset.assetId, assetId) + ) + ) + .limit(1) + + if (!existingAssetInCollection) { + return ctx.json( + { + success: false, + message: "Asset is not in collection", + }, + 400 + ) + } + + await drizzle + .delete(userCollectionAsset) + .where( + and( + eq(userCollectionAsset.collectionId, id), + eq(userCollectionAsset.assetId, assetId) + ) + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/remove-contributor.ts b/src/v2/routes/collection/remove-contributor.ts new file mode 100644 index 0000000..c01f3d5 --- /dev/null +++ b/src/v2/routes/collection/remove-contributor.ts @@ -0,0 +1,129 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { userCollection, userCollectionCollaborators } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to remove contributor from", + required: true, + }, + }), + contributorId: z.string().openapi({ + param: { + name: "contributorId", + in: "path", + description: "The user ID of the contribitor to remove.", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/contributor/{contributorId}/remove", + method: "post", + summary: "Remove collection contributor", + description: + "Remove a contributor from a collection, you must be the collection owner to remove a contributor.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: + "Returns true if the contributor was removed from the collection.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const RemoveContributorFromCollectionRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id, contributorId } = ctx.req.valid("param") + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + const [existingCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + + if (!existingCollection || existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [contributor] = await drizzle + .select({ + collectionId: userCollectionCollaborators.collectionId, + collaboratorId: userCollectionCollaborators.collaboratorId, + }) + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq( + userCollectionCollaborators.collaboratorId, + contributorId + ) + ) + ) + .limit(1) + + if (!contributor) { + return ctx.json( + { + success: false, + message: "Contributor not found in collection", + }, + 400 + ) + } + + await drizzle + .delete(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq( + userCollectionCollaborators.collaboratorId, + contributorId + ) + ) + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/unlike-collection.ts b/src/v2/routes/collection/unlike-collection.ts new file mode 100644 index 0000000..cca3a47 --- /dev/null +++ b/src/v2/routes/collection/unlike-collection.ts @@ -0,0 +1,115 @@ +import { type AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { userCollection, userCollectionLikes } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the collection to unlike.", + example: "collection_id", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/unlike", + method: "post", + summary: "Unlike a collection", + description: "Unlike a collection from their ID.", + tags: ["Collection"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the collection was unliked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UnlikeCollectionByIDRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const assetId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + const [existingCollection] = await drizzle + .select() + .from(userCollection) + .where(eq(userCollection.id, assetId)) + .limit(1) + + if ( + !existingCollection || + (!existingCollection.isPublic && + existingCollection.userId !== user.id) + ) { + return ctx.json( + { + success: false, + message: "Collection not found", + }, + 404 + ) + } + + const [collectionLikeStatus] = await drizzle + .select() + .from(userCollectionLikes) + .where( + and( + eq(userCollectionLikes.collectionId, assetId), + eq(userCollectionLikes.likedById, user.id) + ) + ) + .limit(1) + + if (!collectionLikeStatus) { + return ctx.json( + { + success: false, + message: "Collection not liked", + }, + 400 + ) + } + + await drizzle + .delete(userCollectionLikes) + .where( + and( + eq(userCollectionLikes.collectionId, assetId), + eq(userCollectionLikes.likedById, user.id) + ) + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/collection/update-contributor-status.ts b/src/v2/routes/collection/update-contributor-status.ts new file mode 100644 index 0000000..1b97117 --- /dev/null +++ b/src/v2/routes/collection/update-contributor-status.ts @@ -0,0 +1,156 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { userCollection, userCollectionCollaborators } from "@/v2/db/schema" +import { and, eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import type { CollaboratorsRoles } from "@/v2/db/schema" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the collection to add contributor to", + required: true, + }, + }), + contributorId: z.string().openapi({ + param: { + name: "contributorId", + in: "path", + description: "The user ID of the contribitor to add.", + required: true, + }, + }), +}) + +const bodySchema = z.object({ + role: z.string().optional().openapi({ + description: "The role of the contributor.", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/contributor/{contributorId}/update", + method: "patch", + summary: "Modify collection contributor", + description: + "Modify the role of a contributor in a collection, you must be the collection owner to modify a contributor.", + tags: ["Collection"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: + "Returns true if the contributor was modified in the collection.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UpdateContributorStatusRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id, contributorId } = ctx.req.valid("param") + const { role } = ctx.req.valid("json") + + if (role && !["viewer", "collaborator", "editor"].includes(role)) { + return ctx.json( + { + success: false, + message: "Invalid role", + }, + 400 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + const [existingCollection] = await drizzle + .select({ + id: userCollection.id, + userId: userCollection.userId, + }) + .from(userCollection) + .where(eq(userCollection.id, id)) + + if (!existingCollection || existingCollection.userId !== user.id) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const [contributor] = await drizzle + .select({ + collectionId: userCollectionCollaborators.collectionId, + collaboratorId: userCollectionCollaborators.collaboratorId, + }) + .from(userCollectionCollaborators) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq( + userCollectionCollaborators.collaboratorId, + contributorId + ) + ) + ) + .limit(1) + + if (!contributor) { + return ctx.json( + { + success: false, + message: "Contributor not found", + }, + 400 + ) + } + + await drizzle + .update(userCollectionCollaborators) + .set({ role: role as CollaboratorsRoles }) + .where( + and( + eq(userCollectionCollaborators.collectionId, id), + eq( + userCollectionCollaborators.collaboratorId, + contributorId + ) + ) + ) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/contributors/all-contributors.ts b/src/v2/routes/contributors/all-contributors.ts new file mode 100644 index 0000000..5bdc69e --- /dev/null +++ b/src/v2/routes/contributors/all-contributors.ts @@ -0,0 +1,70 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { authUser } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { selectUserSchema } from "@/v2/db/schema" +import { z } from "zod" + +const responseSchema = z.object({ + success: z.literal(true), + contributors: selectUserSchema + .pick({ + id: true, + username: true, + avatarUrl: true, + displayName: true, + usernameColour: true, + plan: true, + role: true, + }) + .array(), +}) + +const openRoute = createRoute({ + path: "/all", + method: "get", + summary: "Get all contributors", + description: "Get a list of all contributors.", + tags: ["Contributors"], + responses: { + 200: { + description: "All Contributors.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + 500: { + description: "Internal server error.", + }, + }, +}) + +export const AllContributorsRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { drizzle } = await getConnection(ctx.env) + + const contributors = await drizzle + .select({ + id: authUser.id, + username: authUser.username, + avatarUrl: authUser.avatarUrl, + displayName: authUser.displayName, + usernameColour: authUser.usernameColour, + plan: authUser.plan, + role: authUser.role, + }) + .from(authUser) + .where(eq(authUser.isContributor, true)) + + return ctx.json( + { + success: true, + contributors, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/contributors/handler.ts b/src/v2/routes/contributors/handler.ts new file mode 100644 index 0000000..7b7200c --- /dev/null +++ b/src/v2/routes/contributors/handler.ts @@ -0,0 +1,8 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { AllContributorsRoute } from "./all-contributors" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AllContributorsRoute(handler) + +export default handler diff --git a/src/v2/routes/game/all-games.ts b/src/v2/routes/game/all-games.ts new file mode 100644 index 0000000..d8d9ca0 --- /dev/null +++ b/src/v2/routes/game/all-games.ts @@ -0,0 +1,47 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { game } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { createRoute } from "@hono/zod-openapi" +import { z } from "@hono/zod-openapi" +import { selectGameSchema } from "@/v2/db/schema" + +const responseSchema = z.object({ + success: z.literal(true), + games: selectGameSchema.array(), +}) + +const openRoute = createRoute({ + path: "/all", + method: "get", + summary: "Get all games", + description: "Get all games.", + tags: ["Game"], + responses: { + 200: { + description: "All games.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AllGamesRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { drizzle } = await getConnection(ctx.env) + + const games = (await drizzle.select().from(game)) ?? [] + + return ctx.json( + { + success: true, + games, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/game/create-game.ts b/src/v2/routes/game/create-game.ts new file mode 100644 index 0000000..17e197c --- /dev/null +++ b/src/v2/routes/game/create-game.ts @@ -0,0 +1,121 @@ +import { AppHandler } from "../handler" +import { game } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectGameSchema } from "@/v2/db/schema" + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The name of the game.", + example: "honkai-star-rail", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The formatted name of the game.", + example: "Honkai: Star Rail", + }), + possibleSuggestiveContent: z + .string() + .min(0) + .max(1) + .openapi({ + description: + "If the game contains suggestive content. 1 = Yes, 0 = No.", + example: "1", + }) + .transform((value) => parseInt(value)) + .refine((value) => value === 1 || value === 0), +}) + +const responseSchema = z.object({ + success: z.literal(true), + game: selectGameSchema, +}) + +const openRoute = createRoute({ + path: "/create", + method: "post", + summary: "Create a game", + description: "Create a new game.", + tags: ["Game"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the new game.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CreateGameRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName, possibleSuggestiveContent } = + ctx.req.valid("json") + + const { drizzle } = getConnection(ctx.env) + + const [gameExists] = await drizzle + .select({ name: game.name }) + .from(game) + .where(eq(game.name, name)) + + if (gameExists.name) { + return ctx.json( + { + success: false, + message: "Game already exists", + }, + 400 + ) + } + + const [newGame] = await drizzle + .insert(game) + .values({ + id: name, + name, + formattedName, + possibleSuggestiveContent: Boolean(possibleSuggestiveContent), + lastUpdated: new Date().toISOString(), + }) + .returning() + + return ctx.json( + { + success: true, + game: newGame, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/game/delete-game.ts b/src/v2/routes/game/delete-game.ts new file mode 100644 index 0000000..ffdf357 --- /dev/null +++ b/src/v2/routes/game/delete-game.ts @@ -0,0 +1,102 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { game } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the game to delete.", + example: "genshin-impact", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/delete", + method: "delete", + summary: "Delete a game", + description: "Delete a game & all its related assets.", + tags: ["Game"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Returns boolean indicating success.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteGameRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundGame] = await drizzle + .select({ id: game.id }) + .from(game) + .where(eq(game.id, id)) + + if (!foundGame) { + return ctx.json( + { + success: false, + message: "Game not found", + }, + 400 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await drizzle.delete(game).where(eq(game.id, id)) + // await ctx.env.FILES_BUCKET.delete("/assets/" + id) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/game/get-game.ts b/src/v2/routes/game/get-game.ts new file mode 100644 index 0000000..615fcb4 --- /dev/null +++ b/src/v2/routes/game/get-game.ts @@ -0,0 +1,78 @@ +import { AppHandler } from "../handler" +import { createRoute } from "@hono/zod-openapi" +import { game } from "@/v2/db/schema" +import { getConnection } from "@/v2/db/turso" +import { eq } from "drizzle-orm" +import { z } from "@hono/zod-openapi" +import { selectGameSchema } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the game to retrieve.", + example: "genshin-impact", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + game: selectGameSchema, +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get a game", + description: "Get a game by their ID.", + tags: ["Game"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Game was found.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetGameByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundGame] = await drizzle + .select() + .from(game) + .where(eq(game.id, id)) + + if (!foundGame) { + return ctx.json( + { + success: false, + message: "Game not found", + }, + 400 + ) + } + + return ctx.json( + { + success: true, + game: foundGame, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/game/handler.ts b/src/v2/routes/game/handler.ts new file mode 100644 index 0000000..56d4dc4 --- /dev/null +++ b/src/v2/routes/game/handler.ts @@ -0,0 +1,16 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { AllGamesRoute } from "./all-games" +import { CreateGameRoute } from "./create-game" +import { DeleteGameRoute } from "./delete-game" +import { GetGameByIdRoute } from "./get-game" +import { ModifyGameRoute } from "./modify-game" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AllGamesRoute(handler) +GetGameByIdRoute(handler) +ModifyGameRoute(handler) +DeleteGameRoute(handler) +CreateGameRoute(handler) + +export default handler diff --git a/src/v2/routes/game/modify-game.ts b/src/v2/routes/game/modify-game.ts new file mode 100644 index 0000000..9e65954 --- /dev/null +++ b/src/v2/routes/game/modify-game.ts @@ -0,0 +1,135 @@ +import { AppHandler } from "../handler" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { game } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectGameSchema } from "@/v2/db/schema" + +export const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + description: "The id of the game to modify.", + example: "honkai-star-rail", + in: "path", + required: true, + }, + }), +}) + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The new name of the game.", + example: "honkai-star-rail", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The new formatted name of the game.", + example: "Honkai: Star Rail", + }), + possibleSuggestiveContent: z + .string() + .min(0) + .max(1) + .openapi({ + description: + "If the game contains suggestive content. 1 = Yes, 0 = No.", + example: "1", + }) + .transform((value) => parseInt(value)) + .refine((value) => value === 1 || value === 0), +}) + +const responseSchema = z.object({ + success: z.literal(true), + game: selectGameSchema, +}) + +const openRoute = createRoute({ + path: "/{id}/modify", + method: "patch", + summary: "Modify a game", + description: "Modify an existing game.", + tags: ["Game"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the game's attributes", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ModifyGameRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName, possibleSuggestiveContent } = + ctx.req.valid("json") + const { id } = ctx.req.valid("param") + + const { drizzle } = getConnection(ctx.env) + + const [existingGame] = await drizzle + .select({ id: game.id }) + .from(game) + .where(eq(game.id, id)) + + if (!existingGame.id) { + return ctx.json( + { + success: false, + message: "Game with ID not found", + }, + 400 + ) + } + + const [updatedGame] = await drizzle + .update(game) + .set({ + name, + formattedName, + possibleSuggestiveContent: Boolean(possibleSuggestiveContent), + lastUpdated: new Date().toISOString(), + }) + .where(eq(game.id, id)) + .returning() + + return ctx.json( + { + success: true, + game: updatedGame, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/handler.ts b/src/v2/routes/handler.ts new file mode 100644 index 0000000..93038c3 --- /dev/null +++ b/src/v2/routes/handler.ts @@ -0,0 +1,26 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import UserRoute from "@/v2/routes/user/handler" +import GameRoute from "@/v2/routes/game/handler" +import AssetRoute from "@/v2/routes/asset/handler" +import TagRoute from "@/v2/routes/tags/handler" +import ContributorRoute from "@/v2/routes/contributors/handler" +import AuthRoute from "@/v2/routes/auth/handler" +import RequestFormRoute from "@/v2/routes/requests/handler" +import CategoriesRoute from "@/v2/routes/category/handler" +import CollectionsRoute from "@/v2/routes/collection/handler" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +handler.route("/game", GameRoute) +handler.route("/asset", AssetRoute) +handler.route("/category", CategoriesRoute) +handler.route("/tags", TagRoute) +handler.route("/user", UserRoute) +handler.route("/contributors", ContributorRoute) +handler.route("/auth", AuthRoute) +handler.route("/request", RequestFormRoute) +handler.route("/collection", CollectionsRoute) + +export default handler + +export type AppHandler = typeof handler diff --git a/src/v2/routes/requests/all-requests.ts b/src/v2/routes/requests/all-requests.ts new file mode 100644 index 0000000..680b4ec --- /dev/null +++ b/src/v2/routes/requests/all-requests.ts @@ -0,0 +1,90 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectRequestFormSchema } from "@/v2/db/schema" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" + +const querySchema = z + .object({ + offset: z.string().openapi({ + param: { + description: + "The offset of requests to return. This is used for pagination.", + name: "offset", + example: "0", + in: "query", + required: false, + }, + }), + }) + .partial() + +const requestFormSchema = z.object({ + ...selectRequestFormSchema.shape, + upvotesCount: z.number().optional(), +}) + +const responseSchema = z.object({ + success: z.literal(true), + requests: z.array(requestFormSchema), +}) + +const openRoute = createRoute({ + path: "/all", + method: "get", + summary: "Get all requests", + description: + "Get all requests & associated upvotes count. Supporter required.", + tags: ["Requests"], + request: { + query: querySchema, + }, + responses: { + 200: { + description: "List of all submitted requests.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AllRequestsRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { offset } = ctx.req.valid("query") ?? { offset: "0" } + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: "Unauthorized. Only supporters can view requests.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const allRequests = await drizzle.query.requestForm.findMany({ + offset: parseInt(offset), + limit: 50, + }) + + return ctx.json( + { + success: true, + requests: allRequests, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/requests/create-request.ts b/src/v2/routes/requests/create-request.ts new file mode 100644 index 0000000..74fbccd --- /dev/null +++ b/src/v2/routes/requests/create-request.ts @@ -0,0 +1,104 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { requestForm, selectRequestFormSchema } from "@/v2/db/schema" +import type { requestArea } from "@/v2/db/schema" + +const requestBodySchema = z.object({ + title: z.string().min(3).max(32).openapi({ + description: "The title of the request.", + example: "Add HSR UI assets", + }), + area: z + .string() + .min(3) + .max(32) + .openapi({ + description: "The area of the request.", + example: "asset", + }) + .transform((value) => value as requestArea), + description: z.string().min(3).max(256).openapi({ + description: "The description of the request.", + example: + "Add the UI assets for Honkai: Star Rail, including the logo and other UI elements.", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + response: selectRequestFormSchema, +}) + +const openRoute = createRoute({ + path: "/create", + method: "post", + summary: "Create request entry", + description: + "Create a new entry into the request form. Supporter required.", + tags: ["Requests"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the new request form entry.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CreateRequestFormEntryRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { area, title, description } = ctx.req.valid("json") + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: + "Unauthorized. Only supporters can create request entries.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [newRequestEntry] = await drizzle + .insert(requestForm) + .values({ + userId: user.id, + title: title, + area: area, + description: description, + }) + .returning() + + return ctx.json( + { + success: true, + response: newRequestEntry, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/requests/delete-request.ts b/src/v2/routes/requests/delete-request.ts new file mode 100644 index 0000000..f1c5678 --- /dev/null +++ b/src/v2/routes/requests/delete-request.ts @@ -0,0 +1,106 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { requestForm } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the request to delete. Supporter required.", + example: "request_id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/delete", + method: "delete", + summary: "Delete a request", + description: + "Delete a request by its ID. This will also delete all associated upvotes.", + tags: ["Requests"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the request was deleted successfully.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteRequestByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const requestId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: + "Unauthorized. Only supporters can delete requests.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [request] = await drizzle + .select({ id: requestForm.id, userId: requestForm.userId }) + .from(requestForm) + .where(eq(requestForm.id, requestId)) + .limit(1) + + if (!request) { + return ctx.json( + { + success: false, + message: "Request by ID not found", + }, + 404 + ) + } + + if (request.userId != user.id) { + return ctx.json( + { + success: false, + message: + "Unauthorized. You can only delete your own requests.", + }, + 401 + ) + } + + await drizzle.delete(requestForm).where(eq(requestForm.id, requestId)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/requests/handler.ts b/src/v2/routes/requests/handler.ts new file mode 100644 index 0000000..cb37929 --- /dev/null +++ b/src/v2/routes/requests/handler.ts @@ -0,0 +1,20 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { AllRequestsRoute } from "./all-requests" +import { DeleteRequestByIdRoute } from "./delete-request" +import { CreateRequestFormEntryRoute } from "./create-request" +import { UpvoteRequestRoute } from "./upvote-request" +import { ViewRequestRoute } from "./view-request" +import { RemoveRequestUpvoteRoute } from "./remove-request-upvote" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AllRequestsRoute(handler) +ViewRequestRoute(handler) + +UpvoteRequestRoute(handler) +RemoveRequestUpvoteRoute(handler) + +CreateRequestFormEntryRoute(handler) +DeleteRequestByIdRoute(handler) + +export default handler diff --git a/src/v2/routes/requests/remove-request-upvote.ts b/src/v2/routes/requests/remove-request-upvote.ts new file mode 100644 index 0000000..38132e2 --- /dev/null +++ b/src/v2/routes/requests/remove-request-upvote.ts @@ -0,0 +1,127 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { requestForm, requestFormUpvotes } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the request to remove the upvote for.", + example: "request_id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/downvote", + method: "post", + summary: "Remove upvote on a request", + description: "Remove a upvote on a request by its ID. Supporter required.", + tags: ["Requests"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: + "True if the request's upvote was removed successfully.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const RemoveRequestUpvoteRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const requestId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: + "Unauthorized. Only supporters can upvote requests.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [request] = await drizzle + .select({ id: requestForm.id, userId: requestForm.userId }) + .from(requestForm) + .where(eq(requestForm.id, requestId)) + .limit(1) + + if (!request) { + return ctx.json( + { + success: false, + message: "Request by ID not found", + }, + 404 + ) + } + + if (request.userId == user.id) { + return ctx.json( + { + success: false, + message: + "Unauthorized. You can't remove upvotes on your own requests.", + }, + 401 + ) + } + + const [isUpvoted] = await drizzle + .select({ + id: requestFormUpvotes.id, + }) + .from(requestFormUpvotes) + .where(eq(requestFormUpvotes.requestFormId, requestId)) + .limit(1) + + if (!isUpvoted) { + return ctx.json( + { + success: false, + message: + "Unauthorized. You can't remove upvotes on requests you haven't upvoted.", + }, + 401 + ) + } + + await drizzle + .delete(requestFormUpvotes) + .where(eq(requestFormUpvotes.requestFormId, requestId)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/requests/upvote-request.ts b/src/v2/routes/requests/upvote-request.ts new file mode 100644 index 0000000..e5eef30 --- /dev/null +++ b/src/v2/routes/requests/upvote-request.ts @@ -0,0 +1,126 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { requestForm, requestFormUpvotes } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the request to upvote.", + example: "request_id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/upvote", + method: "post", + summary: "Upvote a request", + description: "Upvote a request by its ID. Supporter required.", + tags: ["Requests"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the request was upvoted successfully.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UpvoteRequestRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const requestId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: + "Unauthorized. Only supporters can upvote requests.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [request] = await drizzle + .select({ id: requestForm.id, userId: requestForm.userId }) + .from(requestForm) + .where(eq(requestForm.id, requestId)) + .limit(1) + + if (!request) { + return ctx.json( + { + success: false, + message: "Request by ID not found", + }, + 404 + ) + } + + if (request.userId == user.id) { + return ctx.json( + { + success: false, + message: + "Unauthorized. You can't upvote your own requests.", + }, + 401 + ) + } + + const [isUpvoted] = await drizzle + .select({ + id: requestFormUpvotes.id, + }) + .from(requestFormUpvotes) + .where(eq(requestFormUpvotes.requestFormId, requestId)) + .limit(1) + + if (isUpvoted) { + return ctx.json( + { + success: false, + message: "Request already upvoted", + }, + 400 + ) + } + + await drizzle.insert(requestFormUpvotes).values({ + requestFormId: requestId, + userId: user.id, + }) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/requests/view-request.ts b/src/v2/routes/requests/view-request.ts new file mode 100644 index 0000000..06b3f0e --- /dev/null +++ b/src/v2/routes/requests/view-request.ts @@ -0,0 +1,93 @@ +import { AppHandler } from "../handler" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { requestForm, selectRequestFormSchema } from "@/v2/db/schema" +import { eq } from "drizzle-orm" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the request to view.", + example: "request_id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + request: selectRequestFormSchema, +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "View a request", + description: "View a request by its ID. Supporter required.", + tags: ["Requests"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "The request was found and returned successfully.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ViewRequestRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const requestId = ctx.req.valid("param").id + + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator" || user.plan == "supporter") { + return ctx.json( + { + success: false, + message: "Unauthorized. Only supporters can view requests.", + }, + 401 + ) + } + + const { drizzle } = await getConnection(ctx.env) + + const [request] = await drizzle + .select() + .from(requestForm) + .where(eq(requestForm.id, requestId)) + .limit(1) + + if (!request) { + return ctx.json( + { + success: false, + message: "Request by ID not found", + }, + 404 + ) + } + + return ctx.json( + { + success: true, + request: request, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/tags/all-tags.ts b/src/v2/routes/tags/all-tags.ts new file mode 100644 index 0000000..5ef4e47 --- /dev/null +++ b/src/v2/routes/tags/all-tags.ts @@ -0,0 +1,47 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { assetTag } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { createRoute } from "@hono/zod-openapi" +import { z } from "@hono/zod-openapi" +import { selectAssetTagSchema } from "@/v2/db/schema" + +const responseSchema = z.object({ + success: z.literal(true), + tags: selectAssetTagSchema.array(), +}) + +const openRoute = createRoute({ + path: "/all", + method: "get", + summary: "Get all tags", + description: "Get all tags.", + tags: ["Tags"], + responses: { + 200: { + description: "All tags.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const AllTagsRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { drizzle } = await getConnection(ctx.env) + + const tags = (await drizzle.select().from(assetTag)) ?? [] + + return ctx.json( + { + success: true, + tags, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/tags/create-tag.ts b/src/v2/routes/tags/create-tag.ts new file mode 100644 index 0000000..011f94e --- /dev/null +++ b/src/v2/routes/tags/create-tag.ts @@ -0,0 +1,108 @@ +import { AppHandler } from "../handler" +import { assetTag } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectAssetTagSchema } from "@/v2/db/schema" + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The name of the tag.", + example: "official", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The formatted name of the tag.", + example: "Official", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + tag: selectAssetTagSchema, +}) + +const openRoute = createRoute({ + path: "/create", + method: "post", + summary: "Create a tag", + description: "Create a new tag.", + tags: ["Tags"], + request: { + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the new tag.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const CreateTagRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName } = ctx.req.valid("json") + + const { drizzle } = getConnection(ctx.env) + + const [tagExists] = await drizzle + .select({ name: assetTag.name }) + .from(assetTag) + .where(eq(assetTag.name, name)) + + if (tagExists) { + return ctx.json( + { + success: false, + message: "Tag already exists", + }, + 400 + ) + } + + const [newTag] = await drizzle + .insert(assetTag) + .values({ + id: name, + name, + formattedName, + lastUpdated: new Date().toISOString(), + }) + .returning() + + return ctx.json( + { + success: true, + tag: newTag, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/tags/delete-tag.ts b/src/v2/routes/tags/delete-tag.ts new file mode 100644 index 0000000..f202eb0 --- /dev/null +++ b/src/v2/routes/tags/delete-tag.ts @@ -0,0 +1,91 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { assetTag } from "@/v2/db/schema" +import { eq } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the tag to delete.", + example: "official", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/delete", + method: "delete", + summary: "Delete a tag", + description: "Delete a tag.", + tags: ["Tags"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Returns boolean indicating success.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const DeleteTagRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundTag] = await drizzle + .select({ id: assetTag.id }) + .from(assetTag) + .where(eq(assetTag.id, id)) + + if (!foundTag) { + return ctx.json( + { + success: false, + message: "Tag not found", + }, + 400 + ) + } + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + await drizzle.delete(assetTag).where(eq(assetTag.id, id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/tags/get-tag.ts b/src/v2/routes/tags/get-tag.ts new file mode 100644 index 0000000..df35e2c --- /dev/null +++ b/src/v2/routes/tags/get-tag.ts @@ -0,0 +1,78 @@ +import { AppHandler } from "../handler" +import { createRoute } from "@hono/zod-openapi" +import { assetTag } from "@/v2/db/schema" +import { getConnection } from "@/v2/db/turso" +import { eq } from "drizzle-orm" +import { z } from "@hono/zod-openapi" +import { selectAssetTagSchema } from "@/v2/db/schema" +import { GenericResponses } from "@/v2/lib/response-schemas" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + description: "The ID of the tag to retrieve.", + example: "official", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + tag: selectAssetTagSchema, +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get a tag", + description: "Get tag by their ID.", + tags: ["Tags"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "Tag was found.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetTagByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const id = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [foundTag] = await drizzle + .select() + .from(assetTag) + .where(eq(assetTag.id, id)) + + if (!foundTag) { + return ctx.json( + { + success: false, + message: "Tag not found", + }, + 400 + ) + } + + return ctx.json( + { + success: true, + tag: foundTag, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/tags/handler.ts b/src/v2/routes/tags/handler.ts new file mode 100644 index 0000000..704811f --- /dev/null +++ b/src/v2/routes/tags/handler.ts @@ -0,0 +1,16 @@ +import { OpenAPIHono } from "@hono/zod-openapi" +import { AllTagsRoute } from "./all-tags" +import { CreateTagRoute } from "./create-tag" +import { GetTagByIdRoute } from "./get-tag" +import { DeleteTagRoute } from "./delete-tag" +import { ModifyTagRoute } from "./modify-tag" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +AllTagsRoute(handler) +GetTagByIdRoute(handler) +CreateTagRoute(handler) +ModifyTagRoute(handler) +DeleteTagRoute(handler) + +export default handler diff --git a/src/v2/routes/tags/modify-tag.ts b/src/v2/routes/tags/modify-tag.ts new file mode 100644 index 0000000..96ddd7d --- /dev/null +++ b/src/v2/routes/tags/modify-tag.ts @@ -0,0 +1,121 @@ +import { AppHandler } from "../handler" +import { eq } from "drizzle-orm" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { getConnection } from "@/v2/db/turso" +import { assetTag, selectAssetTagSchema } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + description: "The id of the tag to modify.", + example: "official", + in: "path", + required: true, + }, + }), +}) + +const requestBodySchema = z.object({ + name: z.string().min(3).max(32).openapi({ + description: "The new name of the tag.", + example: "official", + }), + formattedName: z.string().min(3).max(64).openapi({ + description: "The new formatted name of the tag.", + example: "Official", + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + tag: selectAssetTagSchema, +}) + +const openRoute = createRoute({ + path: "/{id}/modify", + method: "patch", + summary: "Modify a tag", + description: "Modify an existing tag.", + tags: ["Tags"], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: requestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Returns the tag's attributes", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ModifyTagRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const authSessionManager = new AuthSessionManager(ctx) + + const { user } = await authSessionManager.validateSession() + + if (!user || user.role != "creator") { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + const { name, formattedName } = ctx.req.valid("json") + const { id } = ctx.req.valid("param") + + const { drizzle } = getConnection(ctx.env) + + const [existingTag] = await drizzle + .select({ id: assetTag.id }) + .from(assetTag) + .where(eq(assetTag.id, id)) + + if (!existingTag.id) { + return ctx.json( + { + success: false, + message: "Tag with ID not found", + }, + 400 + ) + } + + const [updatedTag] = await drizzle + .update(assetTag) + .set({ + name, + formattedName, + lastUpdated: new Date().toISOString(), + }) + .where(eq(assetTag.id, id)) + .returning() + + return ctx.json( + { + success: true, + tag: updatedTag, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/block-user.ts b/src/v2/routes/user/block-user.ts new file mode 100644 index 0000000..11a04a5 --- /dev/null +++ b/src/v2/routes/user/block-user.ts @@ -0,0 +1,124 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { and, eq, or } from "drizzle-orm" +import { userBlocked, userFollowing } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the user to block.", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/block", + method: "post", + summary: "Block a user", + description: "Block a user from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the user was blocked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const BlockUserRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (userId == user.id) { + return ctx.json( + { + success: false, + message: "You cannot block yourself", + }, + 400 + ) + } + + const [blockedStatus] = await drizzle + .select({ + id: userBlocked.blockedId, + blockedById: userBlocked.blockedById, + }) + .from(userBlocked) + .where(eq(userBlocked.blockedId, userId)) + + if (blockedStatus) { + return ctx.json( + { + success: false, + message: "User already blocked", + }, + 400 + ) + } + + await drizzle + .delete(userFollowing) + .where( + or( + and( + eq(userFollowing.followerId, user.id), + eq(userFollowing.followingId, userId) + ), + and( + eq(userFollowing.followerId, userId), + eq(userFollowing.followingId, user.id) + ) + ) + ) + + await drizzle + .insert(userBlocked) + .values({ + blockedId: userId, + blockedById: user.id, + }) + .execute() + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/follow-user.ts b/src/v2/routes/user/follow-user.ts new file mode 100644 index 0000000..62a0b5d --- /dev/null +++ b/src/v2/routes/user/follow-user.ts @@ -0,0 +1,154 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { and, eq, or } from "drizzle-orm" +import { userFollowing, userBlocked } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the user to follow.", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/follow", + method: "post", + summary: "Follow a user", + description: "Follow a user from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the user was followed.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const FollowUserRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (userId == user.id) { + return ctx.json( + { + success: false, + message: "You cannot follow yourself", + }, + 400 + ) + } + const [followStatus] = await drizzle + .select({ id: userFollowing.followerId }) + .from(userFollowing) + .where( + and( + eq(userFollowing.followerId, user.id), + eq(userFollowing.followingId, userId) + ) + ) + .limit(1) + + if (followStatus) { + return ctx.json( + { + success: false, + message: "You are already following this user", + }, + 400 + ) + } + + const [blockedStatus] = await drizzle + .select({ + id: userBlocked.blockedId, + blockById: userBlocked.blockedById, + }) + .from(userBlocked) + .where( + or( + and( + eq(userBlocked.blockedId, user.id), + eq(userBlocked.blockedById, userId) + ), + and( + eq(userBlocked.blockedId, userId), + eq(userBlocked.blockedById, user.id) + ) + ) + ) + .limit(1) + + if (blockedStatus) { + const message = + blockedStatus.blockById === user.id + ? "You are blocked by this user" + : "You have blocked this user" + + return ctx.json( + { + success: false, + message, + }, + 400 + ) + } + + try { + await drizzle.insert(userFollowing).values({ + followerId: user.id, + followingId: userId, + createdAt: new Date().toISOString(), + }) + } catch (e) { + return ctx.json( + { + success: false, + message: "Failed to follow user.", + }, + 500 + ) + } + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/get-user.ts b/src/v2/routes/user/get-user.ts new file mode 100644 index 0000000..e178029 --- /dev/null +++ b/src/v2/routes/user/get-user.ts @@ -0,0 +1,91 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { eq } from "drizzle-orm" +import { authUser } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectUserSchema } from "@/v2/db/schema" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + required: true, + description: "The ID of the user to retrieve.", + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + user: selectUserSchema.pick({ + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + pronouns: true, + verified: true, + bio: true, + dateJoined: true, + plan: true, + role: true, + }), +}) + +const openRoute = createRoute({ + path: "/{id}", + method: "get", + summary: "Get a user", + description: "Get a user by their ID.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "The user was found.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const GetUserByIdRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const [user] = await drizzle + .select({ + id: authUser.id, + avatarUrl: authUser.avatarUrl, + displayName: authUser.displayName, + username: authUser.username, + usernameColour: authUser.usernameColour, + pronouns: authUser.pronouns, + verified: authUser.verified, + bio: authUser.bio, + dateJoined: authUser.dateJoined, + plan: authUser.plan, + role: authUser.role, + }) + .from(authUser) + .where(eq(authUser.id, userId)) + + return ctx.json( + { + success: true, + user, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/handler.ts b/src/v2/routes/user/handler.ts new file mode 100644 index 0000000..8295374 --- /dev/null +++ b/src/v2/routes/user/handler.ts @@ -0,0 +1,29 @@ +import { OpenAPIHono } from "@hono/zod-openapi" + +import { GetUserByIdRoute } from "./get-user" +import { SearchUsersByUsernameRoute } from "./search-users" + +import { ViewUsersFollowersRoute } from "./user-followers" +import { ViewUsersFollowingRoute } from "./user-following" + +import { FollowUserRoute } from "./follow-user" +import { UnfollowUserRoute } from "./unfollow-user" + +import { BlockUserRoute } from "./block-user" +import { UnblockUserRoute } from "./unblock-user" + +const handler = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>() + +GetUserByIdRoute(handler) +SearchUsersByUsernameRoute(handler) + +ViewUsersFollowersRoute(handler) +ViewUsersFollowingRoute(handler) + +FollowUserRoute(handler) +UnfollowUserRoute(handler) + +BlockUserRoute(handler) +UnblockUserRoute(handler) + +export default handler diff --git a/src/v2/routes/user/search-users.ts b/src/v2/routes/user/search-users.ts new file mode 100644 index 0000000..b09368a --- /dev/null +++ b/src/v2/routes/user/search-users.ts @@ -0,0 +1,91 @@ +import { AppHandler } from "../handler" +import { authUser } from "@/v2/db/schema" +import { or, like } from "drizzle-orm" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectUserSchema } from "@/v2/db/schema" + +const paramsSchema = z.object({ + username: z.string().openapi({ + param: { + name: "username", + in: "path", + required: true, + description: "The username of the user(s) to retrieve.", + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + users: selectUserSchema + .pick({ + id: true, + avatarUrl: true, + displayName: true, + username: true, + usernameColour: true, + pronouns: true, + verified: true, + bio: true, + dateJoined: true, + plan: true, + role: true, + }) + .array(), +}) + +const openRoute = createRoute({ + path: "/search/{username}", + method: "get", + summary: "Search for users", + description: "Search for users by their username.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "User(s) were found.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const SearchUsersByUsernameRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userQuery = ctx.req.valid("param").username + + const { drizzle } = await getConnection(ctx.env) + + const users = await drizzle + .select({ + id: authUser.id, + avatarUrl: authUser.avatarUrl, + displayName: authUser.displayName, + username: authUser.username, + usernameColour: authUser.usernameColour, + pronouns: authUser.pronouns, + verified: authUser.verified, + bio: authUser.bio, + dateJoined: authUser.dateJoined, + plan: authUser.plan, + role: authUser.role, + }) + .from(authUser) + .where(or(like(authUser.username, `%${userQuery}%`))) + .limit(25) + + return ctx.json({ + success: true, + users, + }) + }) +} diff --git a/src/v2/routes/user/unblock-user.ts b/src/v2/routes/user/unblock-user.ts new file mode 100644 index 0000000..4bebe67 --- /dev/null +++ b/src/v2/routes/user/unblock-user.ts @@ -0,0 +1,110 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { and, eq } from "drizzle-orm" +import { userBlocked } from "@/v2/db/schema" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the user to unblock.", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/block", + method: "post", + summary: "Unblock a user", + description: "Unblock a user from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the user was unblocked.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UnblockUserRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (userId == user.id) { + return ctx.json( + { + success: false, + message: "You cannot unblock yourself", + }, + 400 + ) + } + + const [blockedStatus] = await drizzle + .select({ + id: userBlocked.blockedId, + blockedById: userBlocked.blockedById, + }) + .from(userBlocked) + .where( + and( + eq(userBlocked.blockedId, userId), + eq(userBlocked.blockedById, user.id) + ) + ) + + if (!blockedStatus) { + return ctx.json( + { + success: false, + message: "You have not blocked this user", + }, + 400 + ) + } + + await drizzle + .delete(userBlocked) + .where(eq(userBlocked.id, blockedStatus.id)) + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/unfollow-user.ts b/src/v2/routes/user/unfollow-user.ts new file mode 100644 index 0000000..47cda2e --- /dev/null +++ b/src/v2/routes/user/unfollow-user.ts @@ -0,0 +1,158 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { AuthSessionManager } from "@/v2/lib/managers/auth/user-session-manager" +import { userFollowing, userBlocked } from "@/v2/db/schema" +import { and, eq, or } from "drizzle-orm" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "The id of the user to unfollow.", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), +}) + +const openRoute = createRoute({ + path: "/{id}/unfollow", + method: "post", + summary: "Unfollow a user", + description: "Follow a user from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + }, + responses: { + 200: { + description: "True if the user was unfollowed.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const UnfollowUserRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const userId = ctx.req.valid("param").id + + const { drizzle } = await getConnection(ctx.env) + + const authSessionManager = new AuthSessionManager(ctx) + const { user } = await authSessionManager.validateSession() + + if (!user) { + return ctx.json( + { + success: false, + message: "Unauthorized", + }, + 401 + ) + } + + if (userId == user.id) { + return ctx.json( + { + success: false, + message: "You cannot unfollow yourself", + }, + 400 + ) + } + + const [followStatus] = await drizzle + .select({ id: userFollowing.followerId }) + .from(userFollowing) + .where( + and( + eq(userFollowing.followerId, user.id), + eq(userFollowing.followingId, userId) + ) + ) + .limit(1) + + if (!followStatus) { + return ctx.json( + { + success: false, + message: "You are not following this user", + }, + 400 + ) + } + + const [blockedStatus] = await drizzle + .select({ + id: userBlocked.blockedId, + blockById: userBlocked.blockedById, + }) + .from(userBlocked) + .where( + or( + and( + eq(userBlocked.blockedId, user.id), + eq(userBlocked.blockedById, userId) + ), + and( + eq(userBlocked.blockedId, userId), + eq(userBlocked.blockedById, user.id) + ) + ) + ) + .limit(1) + + if (blockedStatus) { + const message = + blockedStatus.blockById === user.id + ? "You are blocked by this user" + : "You have blocked this user" + + return ctx.json( + { + success: false, + message, + }, + 400 + ) + } + + try { + await drizzle + .delete(userFollowing) + .where( + and( + eq(userFollowing.followerId, user.id), + eq(userFollowing.followingId, userId) + ) + ) + } catch (e) { + return ctx.json( + { + success: false, + message: "Failed to unfollow user, does the user exist?", + }, + 500 + ) + } + + return ctx.json( + { + success: true, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/user-followers.ts b/src/v2/routes/user/user-followers.ts new file mode 100644 index 0000000..3248041 --- /dev/null +++ b/src/v2/routes/user/user-followers.ts @@ -0,0 +1,106 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectUserFollowingSchema, selectUserSchema } from "@/v2/db/schema" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "User ID to view who follows them", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const querySchema = z.object({ + offset: z + .string() + .optional() + .openapi({ + param: { + description: "The offset to start at, optional.", + in: "query", + name: "offset", + required: false, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + followers: z.array( + selectUserFollowingSchema.extend({ + follower: selectUserSchema.pick({ + id: true, + avatarUrl: true, + username: true, + plan: true, + verified: true, + displayName: true, + }), + }) + ), +}) + +const openRoute = createRoute({ + path: "/{id}/followers", + method: "get", + summary: "View a user's followers", + description: "View a user's followers from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + query: querySchema, + }, + responses: { + 200: { + description: + "List of a user's followers. Only 100 showed at a time, use pagination.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ViewUsersFollowersRoute = (handler: AppHandler) => { + handler.openapi(openRoute, async (ctx) => { + const { id } = ctx.req.valid("param") + const { offset } = ctx.req.valid("query") + + const { drizzle } = await getConnection(ctx.env) + + const followers = await drizzle.query.userFollowing.findMany({ + where: (userFollowing, { eq }) => eq(userFollowing.followingId, id), + with: { + follower: { + columns: { + id: true, + avatarUrl: true, + username: true, + plan: true, + verified: true, + displayName: true, + }, + }, + }, + limit: 100, + offset: offset ? parseInt(offset) : 0, + }) + + return ctx.json( + { + success: true, + followers, + }, + 200 + ) + }) +} diff --git a/src/v2/routes/user/user-following.ts b/src/v2/routes/user/user-following.ts new file mode 100644 index 0000000..ad4d078 --- /dev/null +++ b/src/v2/routes/user/user-following.ts @@ -0,0 +1,105 @@ +import { AppHandler } from "../handler" +import { getConnection } from "@/v2/db/turso" +import { createRoute } from "@hono/zod-openapi" +import { GenericResponses } from "@/v2/lib/response-schemas" +import { z } from "@hono/zod-openapi" +import { selectUserFollowingSchema, selectUserSchema } from "@/v2/db/schema" + +const paramsSchema = z.object({ + id: z.string().openapi({ + param: { + description: "User ID to view who they're following", + in: "path", + name: "id", + required: true, + }, + }), +}) + +const querySchema = z.object({ + offset: z + .string() + .optional() + .openapi({ + param: { + description: "The offset to start at, optional.", + in: "query", + name: "offset", + required: false, + }, + }), +}) + +const responseSchema = z.object({ + success: z.literal(true), + following: z.array( + selectUserFollowingSchema.extend({ + following: selectUserSchema.pick({ + id: true, + avatarUrl: true, + username: true, + plan: true, + verified: true, + displayName: true, + }), + }) + ), +}) + +const openRoute = createRoute({ + path: "/{id}/following", + method: "get", + summary: "View who a user's following", + description: "View who a user's following from their ID.", + tags: ["User"], + request: { + params: paramsSchema, + query: querySchema, + }, + responses: { + 200: { + description: + "List of who a user's following. Only 100 showed at a time, use pagination.", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + ...GenericResponses, + }, +}) + +export const ViewUsersFollowingRoute = (handler: AppHandler) => + handler.openapi(openRoute, async (ctx) => { + const { id } = ctx.req.valid("param") + const { offset } = ctx.req.valid("query") + + const { drizzle } = await getConnection(ctx.env) + + const following = await drizzle.query.userFollowing.findMany({ + where: (userFollowing, { eq }) => eq(userFollowing.followerId, id), + with: { + following: { + columns: { + id: true, + avatarUrl: true, + username: true, + plan: true, + verified: true, + displayName: true, + }, + }, + }, + limit: 100, + offset: offset ? parseInt(offset) : 0, + }) + + return ctx.json( + { + success: true, + following, + }, + 200 + ) + }) diff --git a/src/worker-configuration.d.ts b/src/worker-configuration.d.ts index 5f5b5a0..d174b07 100644 --- a/src/worker-configuration.d.ts +++ b/src/worker-configuration.d.ts @@ -1,5 +1,32 @@ -interface Env { - DISCORD_TOKEN: string; - bucket: R2Bucket; - database: D1Database; +import { Context } from "hono" + +declare global { + /** + * Environment variables that are required by the API. + */ + type Bindings = { + DISCORD_TOKEN: string + FILES_BUCKET: R2Bucket + ENVIRONMENT: "PROD" | "DEV" + VERY_SECRET_SIGNUP_KEY: string + TURSO_DATABASE_URL: string + TURSO_DATABASE_AUTH_TOKEN: string + DISCORD_CLIENT_ID: string + DISCORD_CLIENT_SECRET: string + DISCORD_REDIRECT_URI: string + RESEND_API_KEY: string + RATE_LIMITER: DurableObjectNamespace + REKOGNITION_LABEL_API_KEY: string + } + + type Variables = { + // + } + + export type APIContext = Context<{ + Bindings: Bindings + Variables: Variables + }> } + +export default global diff --git a/tsconfig.json b/tsconfig.json index 4cdf957..dcb79d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,16 @@ { "compilerOptions": { + "skipLibCheck": true, "target": "es2021", "module": "es2022", "moduleResolution": "node", + "esModuleInterop": true, "lib": ["es2021"], "baseUrl": "./", "paths": { "@/*": ["src/*"] }, - "types": ["@cloudflare/workers-types"] + "types": ["@cloudflare/workers-types", "node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/scripts/seed/seed.mts"] } diff --git a/wrangler.toml b/wrangler.toml index e04a438..bb4bda8 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "api-wanderer-moe" +name = "api" main = "./src/index.ts" compatibility_date = "2023-04-24" @@ -6,13 +6,22 @@ account_id = "ba4e8f7f9ffbc23dba6acd0d9bd3ef46" workers_dev = true [[r2_buckets]] -binding = 'bucket' -bucket_name = 'wanderer-moe' -preview_bucket_name = 'wanderer-moe' +binding = 'FILES_BUCKET' +bucket_name = 'files-wanderer-moe' +preview_bucket_name = 'files-wanderer-moe' +jurisdiction = 'eu' -[[d1_databases]] -binding = 'database' -database_name = 'wanderer-moe-requests' -database_id = '9c180d14-7f79-45c6-b2f5-809b4e75941b' +[durable_objects] +bindings = [ + {name = "RATE_LIMITER", class_name = "RateLimiter" }, +] -[vars] \ No newline at end of file +[[migrations]] +tag = "v1" +new_classes = ["RateLimiter"] + +[vars] +ENVIRONMENT = "DEV" + +[env.production.vars] +ENVIRONMENT = "PROD" \ No newline at end of file