From 7d60654d3710cbcc002c4471aed2ab41b3e16e04 Mon Sep 17 00:00:00 2001 From: Jake Walker Date: Thu, 23 Jan 2025 19:09:45 +0000 Subject: [PATCH] frontend rewrite --- .dockerignore | 2 + .editorconfig | 2 +- Dockerfile | 14 +- README.md | 69 +- Taskfile.yml | 43 + alembic.ini | 119 ++ client/.gitignore | 24 + client/README.md | 28 + client/index.html | 14 + client/openapi-ts.config.ts | 13 + client/package.json | 31 + client/pnpm-lock.yaml | 1518 +++++++++++++++++ client/public/vite.svg | 1 + client/src/admin-layout.tsx | 55 + client/src/auth.ts | 81 + .../src/client/@tanstack/solid-query.gen.ts | 373 ++++ client/src/client/index.ts | 3 + client/src/client/sdk.gen.ts | 193 +++ client/src/client/types.gen.ts | 464 +++++ client/src/components/AuthProvider.tsx | 28 + client/src/components/NotFound.tsx | 10 + client/src/components/Protected.tsx | 7 + .../components/UploadEpisodeAdditional.tsx | 67 + client/src/components/error.tsx | 14 + client/src/components/loading.tsx | 10 + client/src/components/upload-image.tsx | 66 + client/src/components/upload.tsx | 71 + client/src/constants.ts | 1 + client/src/helpers.ts | 21 + client/src/index.css | 68 + client/src/index.tsx | 89 + client/src/routes/Callback.tsx | 17 + client/src/routes/Login.tsx | 16 + client/src/routes/admin/episode.tsx | 173 ++ client/src/routes/admin/podcast.tsx | 208 +++ client/src/routes/admin/podcasts.tsx | 80 + client/src/types/huge-uploader.d.ts | 48 + client/src/vite-env.d.ts | 1 + client/tsconfig.app.json | 32 + client/tsconfig.json | 7 + client/tsconfig.node.json | 24 + client/vite.config.ts | 9 + migrations/README | 1 + migrations/env.py | 77 + migrations/script.py.mako | 27 + .../9efcecc1e58d_initial_migration.py | 57 + pyproject.toml | 7 + scripts/import-episode.py | 90 - scripts/update-pub-date.py | 50 - src/auth.py | 57 + src/episode_file.py | 76 + src/helpers.py | 15 + src/main.py | 610 +++---- src/models.py | 106 +- src/settings.py | 23 +- src/templates/admin_episode_edit.html.j2 | 22 - src/templates/admin_error.html.j2 | 7 - src/templates/admin_feed.html.j2 | 116 -- src/templates/admin_feed_edit.html.j2 | 25 - src/templates/admin_feeds.html.j2 | 14 - src/templates/admin_layout.html.j2 | 11 - src/templates/admin_new.html.j2 | 15 - src/templates/layout.html.j2 | 18 - uv.lock | 141 ++ 64 files changed, 4877 insertions(+), 802 deletions(-) create mode 100644 Taskfile.yml create mode 100644 alembic.ini create mode 100644 client/.gitignore create mode 100644 client/README.md create mode 100644 client/index.html create mode 100644 client/openapi-ts.config.ts create mode 100644 client/package.json create mode 100644 client/pnpm-lock.yaml create mode 100644 client/public/vite.svg create mode 100644 client/src/admin-layout.tsx create mode 100644 client/src/auth.ts create mode 100644 client/src/client/@tanstack/solid-query.gen.ts create mode 100644 client/src/client/index.ts create mode 100644 client/src/client/sdk.gen.ts create mode 100644 client/src/client/types.gen.ts create mode 100644 client/src/components/AuthProvider.tsx create mode 100644 client/src/components/NotFound.tsx create mode 100644 client/src/components/Protected.tsx create mode 100644 client/src/components/UploadEpisodeAdditional.tsx create mode 100644 client/src/components/error.tsx create mode 100644 client/src/components/loading.tsx create mode 100644 client/src/components/upload-image.tsx create mode 100644 client/src/components/upload.tsx create mode 100644 client/src/constants.ts create mode 100644 client/src/helpers.ts create mode 100644 client/src/index.css create mode 100644 client/src/index.tsx create mode 100644 client/src/routes/Callback.tsx create mode 100644 client/src/routes/Login.tsx create mode 100644 client/src/routes/admin/episode.tsx create mode 100644 client/src/routes/admin/podcast.tsx create mode 100644 client/src/routes/admin/podcasts.tsx create mode 100644 client/src/types/huge-uploader.d.ts create mode 100644 client/src/vite-env.d.ts create mode 100644 client/tsconfig.app.json create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite.config.ts create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/9efcecc1e58d_initial_migration.py delete mode 100644 scripts/import-episode.py delete mode 100644 scripts/update-pub-date.py create mode 100644 src/auth.py create mode 100644 src/episode_file.py create mode 100644 src/helpers.py delete mode 100644 src/templates/admin_episode_edit.html.j2 delete mode 100644 src/templates/admin_error.html.j2 delete mode 100644 src/templates/admin_feed.html.j2 delete mode 100644 src/templates/admin_feed_edit.html.j2 delete mode 100644 src/templates/admin_feeds.html.j2 delete mode 100644 src/templates/admin_layout.html.j2 delete mode 100644 src/templates/admin_new.html.j2 delete mode 100644 src/templates/layout.html.j2 diff --git a/.dockerignore b/.dockerignore index f7d9f4b..bab648a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ data uploads .venv +client/node_modules +client/.vite diff --git a/.editorconfig b/.editorconfig index 1716961..8777645 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{yml,yaml}] +[*.{yml,yaml,ts,tsx,js,jsx,json}] indent_size = 2 diff --git a/Dockerfile b/Dockerfile index 06e15c4..0aa3398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +FROM node:18-alpine AS frontend-build + +WORKDIR /app + +COPY client/ . + +RUN npm i -g corepack@latest \ + && corepack enable \ + && pnpm install --frozen-lockfile \ + && pnpm run build + FROM python:alpine WORKDIR /opt @@ -9,8 +20,9 @@ RUN apk add --update --no-cache ffmpeg \ && uv sync --frozen COPY . /opt +COPY --from=frontend-build /app/dist /opt/dist ENV PG_DIRECTORY=/work ENV PG_UPLOADS_DIRECTORY=/uploads -CMD ["uv", "run", "fastapi", "run", "/opt/src/main.py", "--port", "8000"] +CMD ["uv", "run", "uvicorn", "--app-dir", "/opt/src", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index d8c8f43..266d968 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,51 @@ This is a really simple project allowing for quick creation of podcasts. The server provides a simple admin panel to manage podcasts, and the ability to upload episodes. Any uploaded episodes have their audio level normalised, and are re-encoded to AAC which tends to use less disk space than original formats. -## Deployment - -This is designed to sit behind a reverse proxy set up with forward authentication to protect the admin panel. Although, basic authentication should work too. - -```docker -services: - server: - image: git.jakew.me/jakew/podcast-generator:latest - restart: unless-stopped - environment: - - PG_FEEDS=["mypodcast"] - volumes: - - data:/work - -volumes: - data: -``` - -Example reverse proxy configuration for Caddy: - -```caddy -podcast.example.org { - # rewrite the base admin path to have a trailing slash - @admin path /admin /admin/* - - # apply forward authentication to just admin routes - # this is only an example, refer to your auth provider documentation - forward_auth @admin https://auth.example.org { - copy_headers X-Auth-User ... - } - - # proxy requests to the app - reverse_proxy podcast-server:8000 -} -``` - ### Environment Variables | Name | Default | Description | | ---- | ------- | ----------- | | `PG_DIRECTORY` | `./data` (`/data` for Docker) | Where any files are stored. This includes episodes, images and application data. | | `PG_UPLOADS_DIRECTORY` | `./uploads` (`/uploads` for Docker) | Where any currently uploading files are stored. This directory does not need persistence in Docker. | -| `PG_FEEDS` | `["default"]` | A JSON array of the podcast names to be initially created. | +| `PG_OIDC_AUTHORIZE_URL` _(required)_ | | The OpenID Connect authorize endpoint from the authentication provider. | +| `PG_OIDC_TOKEN_URL` _(required)_ | | The OpenID Connect token endpoint from the authentication provider. | +| `PG_OIDC_JWKS_URL` _(required)_ | | The OpenID Connect JWKS endpoint from the authentication provider. | +| `PG_OIDC_AUTHORITY` _(required)_ | | The OpenID Connect issuer URL from the authentication provider. | +| `PG_OIDC_PERMITTED_JWT_AUDIENCES` _(required)_ | | A list of valid audiences from the authentication provider. | +| `PG_OIDC_CLIENT_ID` _(required)_ | | The OpenID Connect client ID from the authentication provider. | +| `PG_OIDC_SUB_JWT_ATTRIBUTE` | `sub` | The JWT token attribute that contains the user's ID. | +| `PG_OIDC_NAME_JWT_ATTRIBUTE` | `name` | The JWT token attribute that contains the user's name or username. | +| `PG_OIDC_SCOPES` | `["openid", "email", "profile", "offline_access"]` | OpenID Connect scopes. | ## Development -This project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed. +### Backend + +This backend of the project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed. ```bash # install dependencies uv sync +# migrate the database +uv run alembic upgrade head + # run server in development mode uv run fastapi dev main.py ``` + +### Frontend + +The frontend of the project is made using Solid.js. To get started, ensure you have [Node.js]() installed, and corepack enabled (with `corepack enable`). + +```bash +cd frontend + +# install dependencies +pnpm install + +# run server in development mode +pnpm run dev +``` + +After changing the backend, run `pnpm run generate-client` to generate the API client for the frontend from the server's OpenAPI schema. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..5ab5ee0 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,43 @@ +version: '3' + +tasks: + setup: + deps: [backend:setup, frontend:setup] + + backend:setup: + internal: true + cmds: + - uv sync + + backend:migrate: + cmds: + - uv run alembic upgrade head + + backend:dev: + deps: [backend:migrate] + cmds: + - uv run fastapi dev src/main.py + + frontend:setup: + internal: true + dir: ./client + cmds: + - pnpm install + + frontend:generate: + internal: true + dir: ./client + cmds: + - pnpm generate-client + + frontend:dev: + dir: ./client + deps: [frontend:generate] + cmds: + - pnpm run dev + + frontend:build: + dir: ./client + deps: [frontend:generate] + cmds: + - pnpm run build diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..b276e79 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = ./src + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///data/data.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..167c567 --- /dev/null +++ b/client/README.md @@ -0,0 +1,28 @@ +## Usage + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +Runs the app in the development mode.
+Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..ab32d42 --- /dev/null +++ b/client/index.html @@ -0,0 +1,14 @@ + + + + + + + + Vite + Solid + TS + + +
+ + + diff --git a/client/openapi-ts.config.ts b/client/openapi-ts.config.ts new file mode 100644 index 0000000..c2ea49e --- /dev/null +++ b/client/openapi-ts.config.ts @@ -0,0 +1,13 @@ +import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + client: "@hey-api/client-fetch", + input: { + path: "http://localhost:8000/openapi.json" + }, + output: "src/client", + plugins: [ + ...defaultPlugins, + "@tanstack/solid-query" + ] +}) diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..ff2901c --- /dev/null +++ b/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "generate-client": "openapi-ts" + }, + "dependencies": { + "@hey-api/client-fetch": "^0.7.0", + "@solidjs/router": "^0.15.3", + "@soorria/solid-dropzone": "^1.0.1", + "@tanstack/solid-query": "^5.64.1", + "bulma": "^1.0.3", + "huge-uploader": "^1.0.6", + "lucide-solid": "^0.473.0", + "oidc-client-ts": "^3.1.0", + "solid-js": "^1.9.3" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.62.1", + "event-target-shim": "^6.0.2", + "typescript": "~5.6.2", + "vite": "^6.0.5", + "vite-plugin-solid": "^2.11.0" + }, + "packageManager": "pnpm@8.12.1+sha1.aa961ffce9b6eaa56307d9b5ff7e984f25b7eb58" +} diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml new file mode 100644 index 0000000..7444e6e --- /dev/null +++ b/client/pnpm-lock.yaml @@ -0,0 +1,1518 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@hey-api/client-fetch': + specifier: ^0.7.0 + version: 0.7.0 + '@solidjs/router': + specifier: ^0.15.3 + version: 0.15.3(solid-js@1.9.4) + '@soorria/solid-dropzone': + specifier: ^1.0.1 + version: 1.0.1(solid-js@1.9.4) + '@tanstack/solid-query': + specifier: ^5.64.1 + version: 5.64.1(solid-js@1.9.4) + bulma: + specifier: ^1.0.3 + version: 1.0.3 + huge-uploader: + specifier: ^1.0.6 + version: 1.0.6 + lucide-solid: + specifier: ^0.473.0 + version: 0.473.0(solid-js@1.9.4) + oidc-client-ts: + specifier: ^3.1.0 + version: 3.1.0 + solid-js: + specifier: ^1.9.3 + version: 1.9.4 + +devDependencies: + '@hey-api/openapi-ts': + specifier: ^0.62.1 + version: 0.62.1(typescript@5.6.3) + event-target-shim: + specifier: ^6.0.2 + version: 6.0.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^6.0.5 + version: 6.0.7 + vite-plugin-solid: + specifier: ^2.11.0 + version: 2.11.0(solid-js@1.9.4)(vite@6.0.7) + +packages: + + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@babel/code-frame@7.26.2: + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + dev: true + + /@babel/compat-data@7.26.5: + resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.26.0: + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/helper-compilation-targets': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helpers': 7.26.0 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.26.5: + resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + dev: true + + /@babel/helper-compilation-targets@7.26.5: + resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.26.5 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-module-imports@7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.26.5 + dev: true + + /@babel/helper-module-imports@7.25.9: + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0): + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.26.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-plugin-utils@7.26.5: + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-string-parser@7.25.9: + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.25.9: + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.25.9: + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.26.0: + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + dev: true + + /@babel/parser@7.26.5: + resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.26.5 + dev: true + + /@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0): + resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + dev: true + + /@babel/template@7.25.9: + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + dev: true + + /@babel/traverse@7.26.5: + resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types@7.26.5: + resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + dev: true + + /@esbuild/aix-ppc64@0.24.2: + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.24.2: + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.24.2: + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.24.2: + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.24.2: + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.24.2: + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.24.2: + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.24.2: + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.24.2: + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.24.2: + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.24.2: + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.24.2: + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.24.2: + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.24.2: + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.24.2: + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.24.2: + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.24.2: + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-arm64@0.24.2: + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.24.2: + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.24.2: + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.24.2: + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.24.2: + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.24.2: + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.24.2: + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.24.2: + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@hey-api/client-fetch@0.7.0: + resolution: {integrity: sha512-jtoAJ74fmt8+bkWmQOsynB1TomUYA2hf95RyUzzB3ksegn9we3ohkKbiMnJhe4Ae2O2KHuNjkW5fgmUoIsnyoQ==} + dev: false + + /@hey-api/json-schema-ref-parser@1.0.1: + resolution: {integrity: sha512-dBt0A7op9kf4BcK++x6HBYDmvCvnJUZEGe5QytghPFHnMXPyKwDKomwL/v5e9ERk6E0e1GzL/e/y6pWUso9zrQ==} + engines: {node: '>= 16'} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.0 + dev: true + + /@hey-api/openapi-ts@0.62.1(typescript@5.6.3): + resolution: {integrity: sha512-iCqeu3jhlMBBhAFu3ZCSww5fV4M9xhYhL06QXjwXso4CSfrCpEGw03wTXOphWI1mmZUl8/eNn3DKdP7QpewhHA==} + engines: {node: ^18.20.5 || ^20.11.1 || >=22.11.0} + hasBin: true + peerDependencies: + typescript: ^5.5.3 + dependencies: + '@hey-api/json-schema-ref-parser': 1.0.1 + c12: 2.0.1 + commander: 13.0.0 + handlebars: 4.7.8 + typescript: 5.6.3 + transitivePeerDependencies: + - magicast + dev: true + + /@jridgewell/gen-mapping@0.3.8: + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.5.0: + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + dev: true + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + + /@jsdevtools/ono@7.1.3: + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + dev: true + + /@rollup/rollup-android-arm-eabi@4.30.1: + resolution: {integrity: sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.30.1: + resolution: {integrity: sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.30.1: + resolution: {integrity: sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.30.1: + resolution: {integrity: sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-freebsd-arm64@4.30.1: + resolution: {integrity: sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-freebsd-x64@4.30.1: + resolution: {integrity: sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.30.1: + resolution: {integrity: sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-musleabihf@4.30.1: + resolution: {integrity: sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.30.1: + resolution: {integrity: sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.30.1: + resolution: {integrity: sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-loongarch64-gnu@4.30.1: + resolution: {integrity: sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-powerpc64le-gnu@4.30.1: + resolution: {integrity: sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.30.1: + resolution: {integrity: sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-s390x-gnu@4.30.1: + resolution: {integrity: sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.30.1: + resolution: {integrity: sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.30.1: + resolution: {integrity: sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.30.1: + resolution: {integrity: sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.30.1: + resolution: {integrity: sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.30.1: + resolution: {integrity: sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@solidjs/router@0.15.3(solid-js@1.9.4): + resolution: {integrity: sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==} + peerDependencies: + solid-js: ^1.8.6 + dependencies: + solid-js: 1.9.4 + dev: false + + /@soorria/solid-dropzone@1.0.1(solid-js@1.9.4): + resolution: {integrity: sha512-xQ6qkn3IK3IgAz7yyx+AgfmnCnRHkBTqQGjTcS64FDN7YnnvpZB//0rZjJKl5xwG9bGcJ3oyJAHnv2NK/KNSZA==} + peerDependencies: + solid-js: '>=1.0.0' + dependencies: + file-selector: 0.6.0 + solid-js: 1.9.4 + dev: false + + /@tanstack/query-core@5.64.1: + resolution: {integrity: sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==} + dev: false + + /@tanstack/solid-query@5.64.1(solid-js@1.9.4): + resolution: {integrity: sha512-Omlnn8TfvRWh7Pq2HT8HBVngn0YTY19YOz5VTn3xtFVSnOZky6GKf6+Mwm2ovl8NfVQGeeBQGld4KMwzzc+vkg==} + peerDependencies: + solid-js: ^1.6.0 + dependencies: + '@tanstack/query-core': 5.64.1 + solid-js: 1.9.4 + dev: false + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + dev: true + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.26.5 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + dev: true + + /@types/babel__traverse@7.20.6: + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + dependencies: + '@babel/types': 7.26.5 + dev: true + + /@types/estree@1.0.6: + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /babel-plugin-jsx-dom-expressions@0.39.5(@babel/core@7.26.0): + resolution: {integrity: sha512-dwyVkszHRsZCXfFusu3xq1DJS7twhgLrjEpMC1gtTfJG1xSrMMKWWhdl1SFFFNXrvYDsoHiRxSbku/TzLxHNxg==} + peerDependencies: + '@babel/core': ^7.20.12 + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.5 + html-entities: 2.3.3 + parse5: 7.2.1 + validate-html-nesting: 1.2.2 + dev: true + + /babel-preset-solid@1.9.3(@babel/core@7.26.0): + resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.26.0 + babel-plugin-jsx-dom-expressions: 0.39.5(@babel/core@7.26.0) + dev: true + + /browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001692 + electron-to-chromium: 1.5.83 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + dev: true + + /bulma@1.0.3: + resolution: {integrity: sha512-9eVXBrXwlU337XUXBjIIq7i88A+tRbJYAjXQjT/21lwam+5tpvKF0R7dCesre9N+HV9c6pzCNEPKrtgvBBes2g==} + dev: false + + /c12@2.0.1: + resolution: {integrity: sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + dependencies: + chokidar: 4.0.3 + confbox: 0.1.8 + defu: 6.1.4 + dotenv: 16.4.7 + giget: 1.2.3 + jiti: 2.4.2 + mlly: 1.7.4 + ohash: 1.1.4 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + rc9: 2.1.2 + dev: true + + /caniuse-lite@1.0.30001692: + resolution: {integrity: sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==} + dev: true + + /chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + dependencies: + readdirp: 4.1.1 + dev: true + + /chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + dev: true + + /citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + dependencies: + consola: 3.4.0 + dev: true + + /commander@13.0.0: + resolution: {integrity: sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==} + engines: {node: '>=18'} + dev: true + + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: true + + /consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + dev: true + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + /debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + + /defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dev: true + + /destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + dev: true + + /dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + dev: true + + /electron-to-chromium@1.5.83: + resolution: {integrity: sha512-LcUDPqSt+V0QmI47XLzZrz5OqILSMGsPFkDYus22rIbgorSvBYEFqq854ltTmUdHkY92FSdAAvsh4jWEULMdfQ==} + dev: true + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + dev: true + + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + dev: true + + /event-target-shim@3.0.2: + resolution: {integrity: sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==} + engines: {node: '>=4'} + dev: false + + /event-target-shim@6.0.2: + resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==} + engines: {node: '>=10.13.0'} + dev: true + + /execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + dev: true + + /file-selector@0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.8.1 + dev: false + + /fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + dev: true + + /giget@1.2.3: + resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==} + hasBin: true + dependencies: + citty: 0.1.6 + consola: 3.4.0 + defu: 6.1.4 + node-fetch-native: 1.6.4 + nypm: 0.3.12 + ohash: 1.1.4 + pathe: 1.1.2 + tar: 6.2.1 + dev: true + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + dev: true + + /html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + dev: true + + /huge-uploader@1.0.6: + resolution: {integrity: sha512-NxMkrpbvqaNLtXzGI64yopeY33o6zrl2uTYy/rBcqAyM7aO5K3f6P6BPCptuBdMllpyU7kJsEaG9QIzXCkXpIQ==} + dependencies: + event-target-shim: 3.0.2 + dev: false + + /human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + dev: true + + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + dev: false + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + + /lucide-solid@0.473.0(solid-js@1.9.4): + resolution: {integrity: sha512-AcmBZle73Z9HOtKI3uXBJ6aRem1kVE4XHuiVrhrIToi8jLlycBewUel6ToBUe2s/5mBtRfMe7ZeVYy6PMqyuOA==} + peerDependencies: + solid-js: ^1.4.7 + dependencies: + solid-js: 1.9.4 + dev: false + + /merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.16 + dev: true + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: true + + /minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + dev: true + + /minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + dev: true + + /mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /mlly@1.7.4: + resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} + dependencies: + acorn: 8.14.0 + pathe: 2.0.2 + pkg-types: 1.3.1 + ufo: 1.5.4 + dev: true + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: true + + /nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + dev: true + + /node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + dev: true + + /node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + dev: true + + /npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + + /nypm@0.3.12: + resolution: {integrity: sha512-D3pzNDWIvgA+7IORhD/IuWzEk4uXv6GsgOxiid4UU3h9oq5IqV1KtPDi63n4sZJ/xcWlr88c0QM2RgN5VbOhFA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + dependencies: + citty: 0.1.6 + consola: 3.4.0 + execa: 8.0.1 + pathe: 1.1.2 + pkg-types: 1.3.1 + ufo: 1.5.4 + dev: true + + /ohash@1.1.4: + resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==} + dev: true + + /oidc-client-ts@3.1.0: + resolution: {integrity: sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==} + engines: {node: '>=18'} + dependencies: + jwt-decode: 4.0.0 + dev: false + + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + + /parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + dependencies: + entities: 4.5.0 + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + + /pathe@2.0.2: + resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==} + dev: true + + /perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + dev: true + + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + dev: true + + /pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + dependencies: + confbox: 0.1.8 + mlly: 1.7.4 + pathe: 2.0.2 + dev: true + + /postcss@8.5.1: + resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + dev: true + + /rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + dependencies: + defu: 6.1.4 + destr: 2.0.3 + dev: true + + /readdirp@4.1.1: + resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==} + engines: {node: '>= 14.18.0'} + dev: true + + /rollup@4.30.1: + resolution: {integrity: sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.30.1 + '@rollup/rollup-android-arm64': 4.30.1 + '@rollup/rollup-darwin-arm64': 4.30.1 + '@rollup/rollup-darwin-x64': 4.30.1 + '@rollup/rollup-freebsd-arm64': 4.30.1 + '@rollup/rollup-freebsd-x64': 4.30.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.30.1 + '@rollup/rollup-linux-arm-musleabihf': 4.30.1 + '@rollup/rollup-linux-arm64-gnu': 4.30.1 + '@rollup/rollup-linux-arm64-musl': 4.30.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.30.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.30.1 + '@rollup/rollup-linux-riscv64-gnu': 4.30.1 + '@rollup/rollup-linux-s390x-gnu': 4.30.1 + '@rollup/rollup-linux-x64-gnu': 4.30.1 + '@rollup/rollup-linux-x64-musl': 4.30.1 + '@rollup/rollup-win32-arm64-msvc': 4.30.1 + '@rollup/rollup-win32-ia32-msvc': 4.30.1 + '@rollup/rollup-win32-x64-msvc': 4.30.1 + fsevents: 2.3.3 + dev: true + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + dev: true + + /seroval-plugins@1.2.0(seroval@1.2.0): + resolution: {integrity: sha512-hULTbfzSe81jGWLH8TAJjkEvw6JWMqOo9Uq+4V4vg+HNq53hyHldM9ZOfjdzokcFysiTp9aFdV2vJpZFqKeDjQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + dependencies: + seroval: 1.2.0 + + /seroval@1.2.0: + resolution: {integrity: sha512-GURoU99ko2UiAgUC3qDCk59Jb3Ss4Po8VIMGkG8j5PFo2Q7y0YSMP8QG9NuL/fJCoTz9V1XZUbpNIMXPOfaGpA==} + engines: {node: '>=10'} + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /solid-js@1.9.4: + resolution: {integrity: sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g==} + dependencies: + csstype: 3.1.3 + seroval: 1.2.0 + seroval-plugins: 1.2.0(seroval@1.2.0) + + /solid-refresh@0.6.3(solid-js@1.9.4): + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + dependencies: + '@babel/generator': 7.26.5 + '@babel/helper-module-imports': 7.25.9 + '@babel/types': 7.26.5 + solid-js: 1.9.4 + transitivePeerDependencies: + - supports-color + dev: true + + /source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + + /tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + dev: true + + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + dev: false + + /typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + dev: true + + /uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + + /update-browserslist-db@1.1.2(browserslist@4.24.4): + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + dev: true + + /validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + dev: true + + /vite-plugin-solid@2.11.0(solid-js@1.9.4)(vite@6.0.7): + resolution: {integrity: sha512-G+NiwDj4EAeUE0wt3Ur9f+Lt9oMUuLd0FIxYuqwJSqRacKQRteCwUFzNy8zMEt88xWokngQhiFjfJMhjc1fDXw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + dependencies: + '@babel/core': 7.26.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.3(@babel/core@7.26.0) + merge-anything: 5.1.7 + solid-js: 1.9.4 + solid-refresh: 0.6.3(solid-js@1.9.4) + vite: 6.0.7 + vitefu: 1.0.5(vite@6.0.7) + transitivePeerDependencies: + - supports-color + dev: true + + /vite@6.0.7: + resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + dependencies: + esbuild: 0.24.2 + postcss: 8.5.1 + rollup: 4.30.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitefu@1.0.5(vite@6.0.7): + resolution: {integrity: sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 6.0.7 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/admin-layout.tsx b/client/src/admin-layout.tsx new file mode 100644 index 0000000..9730137 --- /dev/null +++ b/client/src/admin-layout.tsx @@ -0,0 +1,55 @@ +import { A } from "@solidjs/router"; +import { createQuery } from "@tanstack/solid-query"; +import { children, createSignal, ErrorBoundary, ParentProps, Suspense } from "solid-js"; +import { readUserOptions } from "./client/@tanstack/solid-query.gen"; +import { logout } from "./auth"; + +export default function AdminLayout(props: ParentProps) { + const content = children(() => props.children); + const [navbarToggled, setNavbarToggled] = createSignal(false); + + const userQuery = createQuery(() => ({ + ...readUserOptions(), + })); + + return <> + +
+ {content()} +
+ ; +} diff --git a/client/src/auth.ts b/client/src/auth.ts new file mode 100644 index 0000000..b51cc8a --- /dev/null +++ b/client/src/auth.ts @@ -0,0 +1,81 @@ +import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; +import { createSignal } from "solid-js"; +import { getAppConfig } from "./client"; + +let userManager: UserManager | null = null; + +export async function getUserManager() { + if (userManager) return userManager; + + const res = await getAppConfig(); + const config = res.data; + + if (!config) { + throw new Error("Failed to get app configuration from server"); + } + + userManager = new UserManager({ + authority: config.oidc_authority, + client_id: config.oidc_client_id, + redirect_uri: window.location.origin + "/callback", + response_type: "code", + scope: config.oidc_scopes.join(" "), + userStore: new WebStorageStateStore({ + store: window.localStorage, + }), + automaticSilentRenew: false, + }); + + return userManager; +} + +const [user, setUser] = createSignal(null); + +export async function login() { + await (await getUserManager()).signinRedirect(); +} + +export async function logout() { + await (await getUserManager()).signoutRedirect(); + setUser(null); +} + +export async function getToken() { + const user = await (await getUserManager()).getUser(); + return user?.access_token; +} + +export async function fetchUser() { + const currentUser = await (await getUserManager()).getUser(); + if (currentUser && !currentUser.expired) { + setUser(currentUser); + } else { + await refreshToken(); + } +} + +export async function refreshToken() { + try { + const currentUser = await (await getUserManager()).getUser(); + if (!currentUser?.refresh_token) { + console.error("No refresh token available, re-authentication required"); + setUser(null); + return; + } + + const refreshedUser = await (await getUserManager()).signinSilent(); + + if (refreshedUser) { + console.log("Token refreshed"); + setUser(refreshedUser); + } else { + console.error("Token refresh failed"); + setUser(null); + } + } catch (err) { + console.error("Error refreshing token:", err); + setUser(null); + } +} + +export { user }; diff --git a/client/src/client/@tanstack/solid-query.gen.ts b/client/src/client/@tanstack/solid-query.gen.ts new file mode 100644 index 0000000..d7830bc --- /dev/null +++ b/client/src/client/@tanstack/solid-query.gen.ts @@ -0,0 +1,373 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Options } from '@hey-api/client-fetch'; +import { queryOptions, type MutationOptions } from '@tanstack/solid-query'; +import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData } from '../types.gen'; +import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, client } from '../sdk.gen'; + +type QueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + } +]; + +const createQueryKey = (id: string, options?: TOptions, infinite?: boolean): QueryKey[0] => { + const params: QueryKey[0] = { _id: id, baseUrl: (options?.client ?? client).getConfig().baseUrl } as QueryKey[0]; + if (infinite) { + params._infinite = infinite; + } + if (options?.body) { + params.body = options.body; + } + if (options?.headers) { + params.headers = options.headers; + } + if (options?.path) { + params.path = options.path; + } + if (options?.query) { + params.query = options.query; + } + return params; +}; + +export const getAppConfigQueryKey = (options?: Options) => [ + createQueryKey('getAppConfig', options) +]; + +export const getAppConfigOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAppConfig({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getAppConfigQueryKey(options) + }); +}; + +export const readUserQueryKey = (options?: Options) => [ + createQueryKey('readUser', options) +]; + +export const readUserOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await readUser({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: readUserQueryKey(options) + }); +}; + +export const readPodcastsQueryKey = (options?: Options) => [ + createQueryKey('readPodcasts', options) +]; + +export const readPodcastsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await readPodcasts({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: readPodcastsQueryKey(options) + }); +}; + +export const createPodcastQueryKey = (options: Options) => [ + createQueryKey('createPodcast', options) +]; + +export const createPodcastOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createPodcast({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: createPodcastQueryKey(options) + }); +}; + +export const createPodcastMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await createPodcast({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const deletePodcastMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await deletePodcast({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const readPodcastQueryKey = (options: Options) => [ + createQueryKey('readPodcast', options) +]; + +export const readPodcastOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await readPodcast({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: readPodcastQueryKey(options) + }); +}; + +export const updatePodcastMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updatePodcast({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const updatePodcastImageQueryKey = (options: Options) => [ + createQueryKey('updatePodcastImage', options) +]; + +export const updatePodcastImageOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await updatePodcastImage({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: updatePodcastImageQueryKey(options) + }); +}; + +export const updatePodcastImageMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updatePodcastImage({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const readEpisodesQueryKey = (options: Options) => [ + createQueryKey('readEpisodes', options) +]; + +export const readEpisodesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await readEpisodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: readEpisodesQueryKey(options) + }); +}; + +export const adminUploadEpisodeQueryKey = (options: Options) => [ + createQueryKey('adminUploadEpisode', options) +]; + +export const adminUploadEpisodeOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await adminUploadEpisode({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: adminUploadEpisodeQueryKey(options) + }); +}; + +export const adminUploadEpisodeMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await adminUploadEpisode({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const deleteEpisodeMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await deleteEpisode({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const readEpisodeQueryKey = (options: Options) => [ + createQueryKey('readEpisode', options) +]; + +export const readEpisodeOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await readEpisode({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: readEpisodeQueryKey(options) + }); +}; + +export const updateEpisodeMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateEpisode({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const episodeAdditionalUploadQueryKey = (options: Options) => [ + createQueryKey('episodeAdditionalUpload', options) +]; + +export const episodeAdditionalUploadOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await episodeAdditionalUpload({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: episodeAdditionalUploadQueryKey(options) + }); +}; + +export const episodeAdditionalUploadMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await episodeAdditionalUpload({ + ...options, + ...localOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getPodcastFeedQueryKey = (options: Options) => [ + createQueryKey('getPodcastFeed', options) +]; + +export const getPodcastFeedOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getPodcastFeed({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getPodcastFeedQueryKey(options) + }); +}; + +export const getEpisodeOrCoverQueryKey = (options: Options) => [ + createQueryKey('getEpisodeOrCover', options) +]; + +export const getEpisodeOrCoverOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEpisodeOrCover({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getEpisodeOrCoverQueryKey(options) + }); +}; \ No newline at end of file diff --git a/client/src/client/index.ts b/client/src/client/index.ts new file mode 100644 index 0000000..e64537d --- /dev/null +++ b/client/src/client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/client/src/client/sdk.gen.ts b/client/src/client/sdk.gen.ts new file mode 100644 index 0000000..e79cf16 --- /dev/null +++ b/client/src/client/sdk.gen.ts @@ -0,0 +1,193 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options, formDataBodySerializer } from '@hey-api/client-fetch'; +import type { GetAppConfigData, GetAppConfigResponse, ReadUserData, ReadUserResponse, ReadPodcastsData, ReadPodcastsResponse, CreatePodcastData, CreatePodcastResponse, CreatePodcastError, DeletePodcastData, DeletePodcastError, ReadPodcastData, ReadPodcastResponse, ReadPodcastError, UpdatePodcastData, UpdatePodcastResponse, UpdatePodcastError, UpdatePodcastImageData, UpdatePodcastImageResponse, UpdatePodcastImageError, ReadEpisodesData, ReadEpisodesResponse, ReadEpisodesError, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, ReadEpisodeResponse, ReadEpisodeError, UpdateEpisodeData, UpdateEpisodeResponse, UpdateEpisodeError, EpisodeAdditionalUploadData, EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, GetPodcastFeedData, GetPodcastFeedError, GetEpisodeOrCoverData, GetEpisodeOrCoverError } from './types.gen'; + +export const client = createClient(createConfig()); + +/** + * Get App Config + */ +export const getAppConfig = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/api/config', + ...options + }); +}; + +/** + * Read User + */ +export const readUser = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/api/user', + ...options + }); +}; + +/** + * Read Podcasts + */ +export const readPodcasts = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/api/podcasts', + ...options + }); +}; + +/** + * Create Podcast + */ +export const createPodcast = (options: Options) => { + return (options?.client ?? client).post({ + url: '/api/podcasts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Delete Podcast + */ +export const deletePodcast = (options: Options) => { + return (options?.client ?? client).delete({ + url: '/api/podcasts/{podcast_id}', + ...options + }); +}; + +/** + * Read Podcast + */ +export const readPodcast = (options: Options) => { + return (options?.client ?? client).get({ + url: '/api/podcasts/{podcast_id}', + ...options + }); +}; + +/** + * Update Podcast + */ +export const updatePodcast = (options: Options) => { + return (options?.client ?? client).patch({ + url: '/api/podcasts/{podcast_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Update Podcast Image + */ +export const updatePodcastImage = (options: Options) => { + return (options?.client ?? client).post({ + ...formDataBodySerializer, + url: '/api/podcasts/{podcast_id}/image', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); +}; + +/** + * Read Episodes + */ +export const readEpisodes = (options: Options) => { + return (options?.client ?? client).get({ + url: '/api/podcasts/{podcast_id}/episodes', + ...options + }); +}; + +/** + * Admin Upload Episode + */ +export const adminUploadEpisode = (options: Options) => { + return (options?.client ?? client).post({ + ...formDataBodySerializer, + url: '/api/podcasts/{podcast_id}/episodes', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); +}; + +/** + * Delete Episode + */ +export const deleteEpisode = (options: Options) => { + return (options?.client ?? client).delete({ + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}', + ...options + }); +}; + +/** + * Read Episode + */ +export const readEpisode = (options: Options) => { + return (options?.client ?? client).get({ + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}', + ...options + }); +}; + +/** + * Update Episode + */ +export const updateEpisode = (options: Options) => { + return (options?.client ?? client).patch({ + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Episode Additional Upload + */ +export const episodeAdditionalUpload = (options: Options) => { + return (options?.client ?? client).post({ + ...formDataBodySerializer, + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); +}; + +/** + * Get Podcast Feed + */ +export const getPodcastFeed = (options: Options) => { + return (options?.client ?? client).get({ + url: '/{podcast_id}.xml', + ...options + }); +}; + +/** + * Get Episode Or Cover + */ +export const getEpisodeOrCover = (options: Options) => { + return (options?.client ?? client).get({ + url: '/{podcast_id}/{filename}', + ...options + }); +}; \ No newline at end of file diff --git a/client/src/client/types.gen.ts b/client/src/client/types.gen.ts new file mode 100644 index 0000000..3d27488 --- /dev/null +++ b/client/src/client/types.gen.ts @@ -0,0 +1,464 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AppConfig = { + oidc_authority: string; + oidc_client_id: string; + oidc_scopes: Array; +}; + +export type BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost = { + file: Blob | File; +}; + +export type BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost = { + file: Blob | File; +}; + +export type BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost = { + image: Blob | File; +}; + +export type CurrentUser = { + user_id: string; + user_name: string; +}; + +export type HttpValidationError = { + detail?: Array; +}; + +export type PodcastCreate = { + name: string; + description?: string; + explicit?: boolean; +}; + +export type PodcastEpisodePublic = { + name: string; + duration?: number | null; + description?: string | null; + file_hash: string; + file_size: number; + publish_date?: string; + id: string; + podcast_id: string; + description_html: string | null; +}; + +export type PodcastEpisodeUpdate = { + name: string | null; + description: string | null; + publish_date?: string | null; +}; + +export type PodcastPublic = { + name: string; + description: string; + explicit?: boolean; + id: string; + image_filename?: string | null; +}; + +export type PodcastUpdate = { + name: string | null; + description: string | null; + explicit?: boolean | null; +}; + +export type ValidationError = { + loc: Array; + msg: string; + type: string; +}; + +export type GetAppConfigData = { + body?: never; + path?: never; + query?: never; + url: '/api/config'; +}; + +export type GetAppConfigResponses = { + /** + * Successful Response + */ + 200: AppConfig; +}; + +export type GetAppConfigResponse = GetAppConfigResponses[keyof GetAppConfigResponses]; + +export type ReadUserData = { + body?: never; + path?: never; + query?: never; + url: '/api/user'; +}; + +export type ReadUserResponses = { + /** + * Successful Response + */ + 200: CurrentUser; +}; + +export type ReadUserResponse = ReadUserResponses[keyof ReadUserResponses]; + +export type ReadPodcastsData = { + body?: never; + path?: never; + query?: never; + url: '/api/podcasts'; +}; + +export type ReadPodcastsResponses = { + /** + * Successful Response + */ + 200: Array; +}; + +export type ReadPodcastsResponse = ReadPodcastsResponses[keyof ReadPodcastsResponses]; + +export type CreatePodcastData = { + body: PodcastCreate; + path?: never; + query?: never; + url: '/api/podcasts'; +}; + +export type CreatePodcastErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreatePodcastError = CreatePodcastErrors[keyof CreatePodcastErrors]; + +export type CreatePodcastResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type CreatePodcastResponse = CreatePodcastResponses[keyof CreatePodcastResponses]; + +export type DeletePodcastData = { + body?: never; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}'; +}; + +export type DeletePodcastErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeletePodcastError = DeletePodcastErrors[keyof DeletePodcastErrors]; + +export type DeletePodcastResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ReadPodcastData = { + body?: never; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}'; +}; + +export type ReadPodcastErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReadPodcastError = ReadPodcastErrors[keyof ReadPodcastErrors]; + +export type ReadPodcastResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type ReadPodcastResponse = ReadPodcastResponses[keyof ReadPodcastResponses]; + +export type UpdatePodcastData = { + body?: PodcastUpdate; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}'; +}; + +export type UpdatePodcastErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdatePodcastError = UpdatePodcastErrors[keyof UpdatePodcastErrors]; + +export type UpdatePodcastResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type UpdatePodcastResponse = UpdatePodcastResponses[keyof UpdatePodcastResponses]; + +export type UpdatePodcastImageData = { + body: BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/image'; +}; + +export type UpdatePodcastImageErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdatePodcastImageError = UpdatePodcastImageErrors[keyof UpdatePodcastImageErrors]; + +export type UpdatePodcastImageResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type UpdatePodcastImageResponse = UpdatePodcastImageResponses[keyof UpdatePodcastImageResponses]; + +export type ReadEpisodesData = { + body?: never; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes'; +}; + +export type ReadEpisodesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReadEpisodesError = ReadEpisodesErrors[keyof ReadEpisodesErrors]; + +export type ReadEpisodesResponses = { + /** + * Successful Response + */ + 200: Array; +}; + +export type ReadEpisodesResponse = ReadEpisodesResponses[keyof ReadEpisodesResponses]; + +export type AdminUploadEpisodeData = { + body: BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost; + path: { + podcast_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes'; +}; + +export type AdminUploadEpisodeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type AdminUploadEpisodeError = AdminUploadEpisodeErrors[keyof AdminUploadEpisodeErrors]; + +export type AdminUploadEpisodeResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type DeleteEpisodeData = { + body?: never; + path: { + podcast_id: string; + episode_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}'; +}; + +export type DeleteEpisodeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteEpisodeError = DeleteEpisodeErrors[keyof DeleteEpisodeErrors]; + +export type DeleteEpisodeResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ReadEpisodeData = { + body?: never; + path: { + podcast_id: string; + episode_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}'; +}; + +export type ReadEpisodeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReadEpisodeError = ReadEpisodeErrors[keyof ReadEpisodeErrors]; + +export type ReadEpisodeResponses = { + /** + * Successful Response + */ + 200: PodcastEpisodePublic; +}; + +export type ReadEpisodeResponse = ReadEpisodeResponses[keyof ReadEpisodeResponses]; + +export type UpdateEpisodeData = { + body: PodcastEpisodeUpdate; + path: { + podcast_id: string; + episode_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}'; +}; + +export type UpdateEpisodeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateEpisodeError = UpdateEpisodeErrors[keyof UpdateEpisodeErrors]; + +export type UpdateEpisodeResponses = { + /** + * Successful Response + */ + 200: PodcastEpisodePublic; +}; + +export type UpdateEpisodeResponse = UpdateEpisodeResponses[keyof UpdateEpisodeResponses]; + +export type EpisodeAdditionalUploadData = { + body: BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost; + path: { + podcast_id: string; + episode_id: string; + }; + query?: never; + url: '/api/podcasts/{podcast_id}/episodes/{episode_id}'; +}; + +export type EpisodeAdditionalUploadErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type EpisodeAdditionalUploadError = EpisodeAdditionalUploadErrors[keyof EpisodeAdditionalUploadErrors]; + +export type EpisodeAdditionalUploadResponses = { + /** + * Successful Response + */ + 200: PodcastEpisodePublic; +}; + +export type EpisodeAdditionalUploadResponse = EpisodeAdditionalUploadResponses[keyof EpisodeAdditionalUploadResponses]; + +export type GetPodcastFeedData = { + body?: never; + path: { + podcast_id: string; + }; + query?: never; + url: '/{podcast_id}.xml'; +}; + +export type GetPodcastFeedErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPodcastFeedError = GetPodcastFeedErrors[keyof GetPodcastFeedErrors]; + +export type GetPodcastFeedResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetEpisodeOrCoverData = { + body?: never; + path: { + podcast_id: string; + filename: string; + }; + query?: never; + url: '/{podcast_id}/{filename}'; +}; + +export type GetEpisodeOrCoverErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetEpisodeOrCoverError = GetEpisodeOrCoverErrors[keyof GetEpisodeOrCoverErrors]; + +export type GetEpisodeOrCoverResponses = { + /** + * Successful Response + */ + 200: unknown; +}; \ No newline at end of file diff --git a/client/src/components/AuthProvider.tsx b/client/src/components/AuthProvider.tsx new file mode 100644 index 0000000..f1316b1 --- /dev/null +++ b/client/src/components/AuthProvider.tsx @@ -0,0 +1,28 @@ +import { createContext, createEffect, createSignal, ParentComponent } from "solid-js"; +import { fetchUser, login, logout, user } from "../auth"; +import { User } from "oidc-client-ts"; + +interface AuthContextType { + user: () => User | null; + login: () => Promise; + logout: () => Promise; +} + +export const AuthContext = createContext(undefined); + +const AuthProvider: ParentComponent = (props) => { + const [isLoading, setIsLoading] = createSignal(true); + + createEffect(async () => { + await fetchUser(); + setIsLoading(false); + }); + + return ( + + {isLoading() ?

Loading...

: props.children} +
+ ); +} + +export default AuthProvider; diff --git a/client/src/components/NotFound.tsx b/client/src/components/NotFound.tsx new file mode 100644 index 0000000..2428910 --- /dev/null +++ b/client/src/components/NotFound.tsx @@ -0,0 +1,10 @@ +export default function NotFound() { + return ( +
+
+

Not Found

+ +
+
+ ) +} diff --git a/client/src/components/Protected.tsx b/client/src/components/Protected.tsx new file mode 100644 index 0000000..b3234a3 --- /dev/null +++ b/client/src/components/Protected.tsx @@ -0,0 +1,7 @@ +import { Navigate } from "@solidjs/router"; +import { user } from "../auth"; +import { ParentProps } from "solid-js"; + +export default function Protected(props: ParentProps) { + return user() ? props.children : ; +} diff --git a/client/src/components/UploadEpisodeAdditional.tsx b/client/src/components/UploadEpisodeAdditional.tsx new file mode 100644 index 0000000..4969430 --- /dev/null +++ b/client/src/components/UploadEpisodeAdditional.tsx @@ -0,0 +1,67 @@ +import { createDropzone } from "@soorria/solid-dropzone"; +import { UploadIcon } from "lucide-solid"; +import { createSignal, Show } from "solid-js"; +import { createMutation, useQueryClient } from "@tanstack/solid-query"; +import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen"; +import { PodcastEpisodePublic } from "../client"; + +export default function UploadEpisodeAdditional(props: { podcastId: string, episodeId: string }) { + const queryClient = useQueryClient(); + + const [uploading, setUploading] = createSignal(false); + const [statusText, setStatusText] = createSignal(null); + const uploadAdditional = createMutation(() => ({ + ...episodeAdditionalUploadMutation(), + onSuccess(data, vars) { + queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData); + queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data); + } + })) + + const dropzone = createDropzone({ + onDrop(files: File[]) { + if (!files[0]) { + throw new Error("No file was uploaded"); + } + + setStatusText("Uploading..."); + setUploading(true); + + uploadAdditional.mutate({ + body: { + file: files[0], + }, + path: { + podcast_id: props.podcastId, + episode_id: props.episodeId + } + }, { + onSuccess() { + setStatusText("Additional file uploaded!"); + setUploading(false); + }, + onError(error, _variables, _context) { + setStatusText(`Error uploading: ${error.detail}`); + setUploading(false); + }, + }); + } + }); + + return <> + +
+ {statusText()} +
+
+ +
+ + +

+ {dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"} +

+
+
+ ; +} diff --git a/client/src/components/error.tsx b/client/src/components/error.tsx new file mode 100644 index 0000000..1afcb10 --- /dev/null +++ b/client/src/components/error.tsx @@ -0,0 +1,14 @@ +export default function Error(props: { message?: any, reset?: () => void }) { + return ( +
+
+

Uh Oh!

+

Something has gone wrong.

+ {props.message && <> +

Error: {props.message.toString()}

+ } + {props.reset && } +
+
+ ); +} diff --git a/client/src/components/loading.tsx b/client/src/components/loading.tsx new file mode 100644 index 0000000..df717e7 --- /dev/null +++ b/client/src/components/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+

One moment...

+ +
+
+ ); +} diff --git a/client/src/components/upload-image.tsx b/client/src/components/upload-image.tsx new file mode 100644 index 0000000..31d618b --- /dev/null +++ b/client/src/components/upload-image.tsx @@ -0,0 +1,66 @@ +import { createDropzone } from "@soorria/solid-dropzone"; +import { UploadIcon } from "lucide-solid"; +import { createSignal, Show } from "solid-js"; +import { createMutation, useQueryClient } from "@tanstack/solid-query"; +import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen"; +import { PodcastPublic } from "../client"; + +export default function UploadImage(props: { podcastId: string }) { + const queryClient = useQueryClient(); + + const [uploading, setUploading] = createSignal(false); + const [statusText, setStatusText] = createSignal(null); + const updatePodcastImage = createMutation(() => ({ + ...updatePodcastImageMutation(), + onSuccess(data, vars) { + queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data); + queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData); + }, + })) + + const dropzone = createDropzone({ + onDrop(files: File[]) { + if (!files[0]) { + throw new Error("No file was uploaded"); + } + + setStatusText("Uploading..."); + setUploading(true); + + updatePodcastImage.mutate({ + body: { + image: files[0], + }, + path: { + podcast_id: props.podcastId, + } + }, { + onSuccess() { + setStatusText("Podcast image uploaded!"); + setUploading(false); + }, + onError(error, _variables, _context) { + setStatusText(`Error uploading: ${error.detail}`); + setUploading(false); + }, + }); + } + }); + + return <> + +
+ {statusText()} +
+
+ +
+ + +

+ {dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"} +

+
+
+ ; +} diff --git a/client/src/components/upload.tsx b/client/src/components/upload.tsx new file mode 100644 index 0000000..d38f272 --- /dev/null +++ b/client/src/components/upload.tsx @@ -0,0 +1,71 @@ +import { createDropzone } from "@soorria/solid-dropzone"; +import { UploadIcon } from "lucide-solid"; +import HugeUploader from "huge-uploader"; +import { SERVER_URL } from "../constants"; +import { createSignal, Show } from "solid-js"; +import { user } from "../auth"; + +export default function Upload(props: { podcastId: string }) { + var uploader: any = null; + const [uploading, setUploading] = createSignal(false); + const [statusText, setStatusText] = createSignal(null); + const [uploadProgress, setUploadProgress] = createSignal(null); + + const dropzone = createDropzone({ + onDrop(files: File[]) { + if (!files[0]) { + throw new Error("No file was uploaded"); + } + + setUploading(true); + setStatusText("Uploading..."); + setUploadProgress(0); + + uploader = new HugeUploader({ + endpoint: new URL(`/api/podcasts/${props.podcastId}/episodes`, SERVER_URL).href, + file: files[0], + headers: { + name: encodeURIComponent(files[0].name), + Authorization: `Bearer ${user()?.access_token}` + }, + }); + + uploader.on("error", (err: any) => { + console.error(err); + setUploading(false); + setStatusText(`Something went wrong: ${err.detail}`); + }); + + uploader.on("progress", (progress: { detail: number }) => { + if (progress.detail == 100) return; + setUploadProgress(progress.detail); + setStatusText(`Uploading (${progress.detail.toFixed(0)}%)...`); + }); + + uploader.on("finish", (body: any) => { + console.log("File uploaded", body); + setUploadProgress(100); + setUploading(false); + setStatusText("Upload complete! The episode will be processed in the background. This may take a few minutes but it's safe to navigate away."); + }); + } + }); + + return <> + +
+ {uploadProgress() && {uploadProgress()}%} + {statusText()} +
+
+ +
+ + +

+ {dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"} +

+
+
+ ; +} diff --git a/client/src/constants.ts b/client/src/constants.ts new file mode 100644 index 0000000..58698f9 --- /dev/null +++ b/client/src/constants.ts @@ -0,0 +1 @@ +export const SERVER_URL = import.meta.env.PROD ? "" : "http://localhost:8000"; diff --git a/client/src/helpers.ts b/client/src/helpers.ts new file mode 100644 index 0000000..719b53e --- /dev/null +++ b/client/src/helpers.ts @@ -0,0 +1,21 @@ +export function humanFileSize(bytes: number, si=false, dp=1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10**dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..6119ad9 --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/client/src/index.tsx b/client/src/index.tsx new file mode 100644 index 0000000..cf90e6e --- /dev/null +++ b/client/src/index.tsx @@ -0,0 +1,89 @@ +/* @refresh reload */ +import { render } from 'solid-js/web' +import { Navigate, Router } from '@solidjs/router' +import { lazy } from 'solid-js'; +import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; +import { client as apiClient } from './client/sdk.gen'; + +import "bulma/css/bulma.min.css"; +import { SERVER_URL } from './constants'; +import Protected from './components/Protected'; +import AuthProvider from './components/AuthProvider'; +import { refreshToken, user } from './auth'; + +const wrapper = document.getElementById('root'); + +if (!wrapper) { + throw new Error("Wrapper div not found"); +} + +const client = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + suspense: true, + throwOnError(error: any, _query) { + if (error instanceof Object && error.hasOwnProperty("detail") && error["detail"].toLowerCase().includes("not found")) { + throw new Error("Not found"); + } + return true; + }, + } + } +}); + +apiClient.setConfig({ + baseUrl: SERVER_URL +}); + +apiClient.interceptors.request.use((request, _options) => { + const u = user(); + if (u !== null) { + request.headers.set("Authorization", `Bearer ${u.access_token}`); + } + return request +}); + +apiClient.interceptors.response.use(async (response, _request, _options) => { + if (response.status === 401 && user()) { + console.warn("Access token expired, trying refresh..."); + await refreshToken(); + } + + return response; +}); + +const routes = [ + { + path: "/", + component: () => , + }, + { + path: "/callback", + component: lazy(() => import("./routes/Callback")), + }, + { + path: "/login", + component: lazy(() => import("./routes/Login")), + }, + { + path: "/admin", + component: () => ( + {lazy(() => import("./routes/admin/podcasts"))()} + ), + }, + { + path: "/admin/:podcastId", + component: () => ( + {lazy(() => import("./routes/admin/podcast"))()} + ), + }, + { + path: "/admin/:podcastId/:episodeId", + component: () => ( + {lazy(() => import("./routes/admin/episode"))()} + ), + } +]; + +render(() => {routes}, wrapper!) diff --git a/client/src/routes/Callback.tsx b/client/src/routes/Callback.tsx new file mode 100644 index 0000000..c387875 --- /dev/null +++ b/client/src/routes/Callback.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from "@solidjs/router" +import { onMount } from "solid-js"; +import { fetchUser, getUserManager } from "../auth"; + +const Callback = () => { + const navigate = useNavigate(); + + onMount(async () => { + await (await getUserManager()).signinRedirectCallback(); + await fetchUser(); + navigate("/admin"); + }); + + return

Signing in...

; +} + +export default Callback; diff --git a/client/src/routes/Login.tsx b/client/src/routes/Login.tsx new file mode 100644 index 0000000..41ac5e9 --- /dev/null +++ b/client/src/routes/Login.tsx @@ -0,0 +1,16 @@ +import { login } from "../auth" + +const Login = () => { + return ( +
+
+
+

Login

+ +
+
+
+ ); +} + +export default Login; diff --git a/client/src/routes/admin/episode.tsx b/client/src/routes/admin/episode.tsx new file mode 100644 index 0000000..f8c6579 --- /dev/null +++ b/client/src/routes/admin/episode.tsx @@ -0,0 +1,173 @@ +import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query"; +import AdminLayout from "../../admin-layout"; +import { createEffect, createSignal, ErrorBoundary, Suspense } from "solid-js"; +import { A, useNavigate, useParams } from "@solidjs/router"; +import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen"; +import Error from "../../components/error"; +import Loading from "../../components/loading"; +import { Save, Trash } from "lucide-solid"; +import { SERVER_URL } from "../../constants"; +import { PodcastEpisodePublic } from "../../client"; +import { humanFileSize } from "../../helpers"; +import UploadEpisodeAdditional from "../../components/UploadEpisodeAdditional"; + +function convertDateToInput(d: Date): string { + const year = d.getFullYear(); + const month = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + const hours = d.getHours().toString().padStart(2, "0"); + const minutes = d.getMinutes().toString().padStart(2, "0"); + + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +export default function AdminEpisode() { + const params = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const episodeQuery = createQuery(() => ({ + ...readEpisodeOptions({ + path: { + podcast_id: params.podcastId, + episode_id: params.episodeId, + } + }), + })); + const updateEpisode = createMutation(() => ({ + ...updateEpisodeMutation(), + onSuccess(data, vars) { + queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData); + queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data); + } + })); + const deleteEpisode = createMutation(() => ({ + ...deleteEpisodeMutation(), + onSuccess(_, vars) { + queryClient.invalidateQueries({ queryKey: readEpisodeQueryKey({ path: vars.path }) }); + queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData.filter((x) => x.id !== vars.path.episode_id)); + } + })); + + const [name, setName] = createSignal(""); + const [description, setDescription] = createSignal(""); + const [publishDate, setPublishDate] = createSignal(""); + + createEffect(() => { + if (episodeQuery.isSuccess && episodeQuery.data) { + setName(episodeQuery.data.name); + setDescription(episodeQuery.data.description || ""); + const date = episodeQuery.data.publish_date ? new Date(episodeQuery.data.publish_date) : new Date(); + setPublishDate(convertDateToInput(date)); + } + }); + + const deleteAction = () => { + if (confirm("Are you sure you want to delete the podcast?")) { + deleteEpisode.mutate({ + path: { + podcast_id: params.podcastId, + episode_id: params.episodeId, + } + }); + navigate(`/admin/${params.podcastId}`); + } + } + + const saveChanges = () => { + updateEpisode.mutate({ + body: { + name: name(), + description: description(), + publish_date: publishDate() + }, + path: { + episode_id: params.episodeId, + podcast_id: params.podcastId + } + }); + }; + + const audioUrl = () => new URL(params.podcastId + "/" + episodeQuery.data?.id + ".m4a", SERVER_URL).href; + + return ( + + }> + }> +
+
+

{episodeQuery.data?.name}

+

{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()} • {episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}

+ {episodeQuery.data?.description_html &&
} +
+
+
+
+

Update Episode

+ + {updateEpisode.isSuccess &&
+ + This episode has been updated successfully. +
} + + {updateEpisode.isError &&
+ + Something went wrong. {JSON.stringify(updateEpisode.error)} +
} + +
+ +
+ setName(e.target.value)} /> +
+
+ +
+ +
+ +
+

The description supports Markdown. Leave this empty to unset the description.

+
+ +
+ +
+ setPublishDate(e.target.value)} /> +
+
+ + +
+
+ +
+
+

Additional Upload

+

Drag extra files here

+ +
+
+ +
+
+

Info

+
    +
  • Audio URL: {audioUrl()}
  • +
  • SHA256 Checksum: {episodeQuery.data?.file_hash}
  • +
  • File Size: {episodeQuery.data?.file_size ? humanFileSize(episodeQuery.data?.file_size, true) : "?"}
  • +
+
+
+ +
+
+

Danger Zone

+ +
+
+
+
+
+ ); +} diff --git a/client/src/routes/admin/podcast.tsx b/client/src/routes/admin/podcast.tsx new file mode 100644 index 0000000..d2291d6 --- /dev/null +++ b/client/src/routes/admin/podcast.tsx @@ -0,0 +1,208 @@ +import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query"; +import AdminLayout from "../../admin-layout"; +import { createEffect, createSignal, ErrorBoundary, For, Match, Show, Suspense, Switch } from "solid-js"; +import { A, useNavigate, useParams } from "@solidjs/router"; +import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen"; +import Error from "../../components/error"; +import Loading from "../../components/loading"; +import { List, Pencil, RefreshCw, Save, Settings, Trash } from "lucide-solid"; +import { SERVER_URL } from "../../constants"; +import Upload from "../../components/upload"; +import UploadImage from "../../components/upload-image"; +import { PodcastPublic } from "../../client"; + +export default function AdminPodcast() { + const params = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const podcastQuery = createQuery(() => ({ + ...readPodcastOptions({ + path: { + podcast_id: params.podcastId + }, + }), + })); + const episodeQuery = createQuery(() => ({ + ...readEpisodesOptions({ + path: { + podcast_id: params.podcastId + }, + }), + })); + const updatePodcast = createMutation(() => ({ + ...updatePodcastMutation(), + onSuccess(data, vars) { + queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data); + queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData); + } + })); + const deletePodcast = createMutation(() => ({ + ...deletePodcastMutation(), + onSuccess(_, vars) { + queryClient.invalidateQueries({ queryKey: readPodcastQueryKey({ path: vars.path }) }); + queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? oldData.filter((x) => x.id !== vars.path.podcast_id) : oldData); + } + })); + + const [tab, setTab] = createSignal(0); + const [name, setName] = createSignal(""); + const [description, setDescription] = createSignal(""); + + createEffect(() => { + if (podcastQuery.isSuccess && podcastQuery.data) { + setName(podcastQuery.data.name); + setDescription(podcastQuery.data.description); + } + }) + + const deleteAction = () => { + if (confirm("Are you sure you want to delete the podcast?")) { + deletePodcast.mutate({ + path: { + podcast_id: params.podcastId, + } + }); + navigate("/admin"); + } + } + + const saveChanges = () => { + updatePodcast.mutate({ + body: { + name: name(), + description: description(), + }, + path: { + podcast_id: params.podcastId + } + }); + } + + return ( + + }> + }> +
+
+
+ +
+
+ +
+
+
+
+

{podcastQuery.data?.name}

+

{podcastQuery.data?.description}

+
+
+ + 0}> +

Subscribe at:

+
{new URL(`${params.podcastId}.xml`, SERVER_URL).href}
+
+
+
+
+ +
+ + + +
+
+ +

Published Episodes

+
+ +
+
+ + + + + + + + + + + + {(episode) => ( + + + + + + + )} + + +
NamePublishedDurationActions
{episode.name}{episode.publish_date ? (new Date(episode.publish_date)).toLocaleString() : "?"}{episode.duration ? `${(episode.duration / 60).toFixed(0)}min` : "?"} + Edit +
+
+
+
+
+ + +
+
+ {updatePodcast.isSuccess &&
+ + This episode has been updated successfully. +
} + + {updatePodcast.isError &&
+ + Something went wrong. {JSON.stringify(updatePodcast.error)} +
} + +
+ +
+ setName(e.target.value)} /> +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
+

Podcast Image

+ +
+
+ +
+
+

Danger Zone

+ +
+
+
+
+
+
+
+ ); +} diff --git a/client/src/routes/admin/podcasts.tsx b/client/src/routes/admin/podcasts.tsx new file mode 100644 index 0000000..c4f2425 --- /dev/null +++ b/client/src/routes/admin/podcasts.tsx @@ -0,0 +1,80 @@ +import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query"; +import AdminLayout from "../../admin-layout"; +import { ErrorBoundary, For, Show, Suspense } from "solid-js"; +import { A } from "@solidjs/router"; +import { createPodcastMutation, readPodcastsOptions, readPodcastsQueryKey } from "../../client/@tanstack/solid-query.gen"; +import Loading from "../../components/loading"; +import Error from "../../components/error"; +import { SERVER_URL } from "../../constants"; +import { Eye, Plus } from "lucide-solid"; +import { PodcastPublic } from "../../client"; + +export default function AdminPodcasts() { + const queryClient = useQueryClient(); + + const query = createQuery(() => ({ + ...readPodcastsOptions(), + })); + + const createPodcast = createMutation(() => ({ + ...createPodcastMutation(), + onSuccess(data) { + queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData, data] : oldData); + }, + })); + + const createAction = () => { + const name = prompt("Enter a podcast name"); + + if (name != null && name.trim().length > 0) { + createPodcast.mutate({ + body: { + name, + description: name + } + }); + } + } + + return ( + + }> + }> +
+
+

Podcasts

+
+ +
+ + {(podcast) => ( +
+
+
+ +
+
+ +
+
+
+
+
{podcast.name}
+

{podcast.description}

+ View +
+
+
+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/client/src/types/huge-uploader.d.ts b/client/src/types/huge-uploader.d.ts new file mode 100644 index 0000000..f416ce2 --- /dev/null +++ b/client/src/types/huge-uploader.d.ts @@ -0,0 +1,48 @@ +import { EventTarget } from "event-target-shim"; + +interface HugeUploaderEventMap { + progress: CustomEvent<{ detail: number }>; + finish: CustomEvent<{ detail: string }>; + error: CustomEvent<{ detail: string | Response }>; + fileRetry: CustomEvent<{ detail: { message: string; chunk: number; retriesLeft: number } }>; + online: Event; + offline: Event; +} + +interface HugeUploaderParams { + endpoint: string; + file: File; + headers?: Record; + postParams?: Record; + chunkSize?: number; + retries?: number; + delayBeforeRetry?: number; +} + +class HugeUploader { + private endpoint: string; + private file: File; + private headers: Record; + private postParams?: Record; + private chunkSize: number; + private retries: number; + private delayBeforeRetry: number; + private start: number; + private chunk: Blob | null; + private chunkCount: number; + private totalChunks: number; + private retriesCount: number; + private offline: boolean; + private paused: boolean; + private _reader: FileReader; + private _eventTarget: EventTarget; + + constructor(params: HugeUploaderParams); + + on(eventType: T, listener: (event: HugeUploaderEventMap[T]) => void): void; + togglePause(): void; +} + +declare module "huge-uploader" { + export default HugeUploader +} diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 0000000..12f076e --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": "./", + "paths": { + "*": ["src/types/*"] + } + }, + "include": ["src", "src/types"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..a92bb6e --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [solid()], + build: { + assetsDir: "static", + } +}) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..778f3b6 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from models import * # noqa: F403 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/9efcecc1e58d_initial_migration.py b/migrations/versions/9efcecc1e58d_initial_migration.py new file mode 100644 index 0000000..5ff3b33 --- /dev/null +++ b/migrations/versions/9efcecc1e58d_initial_migration.py @@ -0,0 +1,57 @@ +"""initial migration + +Revision ID: 9efcecc1e58d +Revises: +Create Date: 2025-02-03 18:45:42.630841 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9efcecc1e58d" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "podcast", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("explicit", sa.Boolean(), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("owner_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("image_filename", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "podcastepisode", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("duration", sa.Float(), nullable=True), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("file_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("file_size", sa.Integer(), nullable=False), + sa.Column("publish_date", sa.DateTime(), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("podcast_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint( + ["podcast_id"], + ["podcast.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("podcastepisode") + op.drop_table("podcast") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 0068746..d441468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,16 +6,23 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "aiofiles>=24.1.0", + "alembic>=1.14.1", + "bleach>=6.2.0", + "cryptography>=44.0.0", "fastapi[standard]>=0.115.6", "ffmpeg-normalize>=1.31.0", "ffmpeg-python>=0.2.0", + "itsdangerous>=2.2.0", "markdown>=3.7", "nanoid>=2.0.0", "pillow>=11.1.0", "podgen>=1.1.0", "pydantic>=2.10.5", "pydantic-settings>=2.7.1", + "pyjwt>=2.10.1", "python-multipart>=0.0.20", + "requests>=2.32.3", "sqlmodel>=0.0.22", "structlog>=24.4.0", + "uvicorn>=0.34.0", ] diff --git a/scripts/import-episode.py b/scripts/import-episode.py deleted file mode 100644 index 1664dc7..0000000 --- a/scripts/import-episode.py +++ /dev/null @@ -1,90 +0,0 @@ -import argparse -import hashlib -import shutil -import sys -from pathlib import Path - -import ffmpeg -import structlog -from sqlmodel import Session, select - -# Add the src directory to the system path -sys.path.append(str(Path(__file__).resolve().parent.parent / "src")) - -import models as models -from settings import settings - -log = structlog.get_logger() - - -def import_episode(filename: Path, podcast_id: str, process: bool, move: bool = True): - if process: - raise NotImplementedError("Importing with processing is not implemented") - - if filename.suffix != ".m4a" and not process: - log.error("Input file must be in an m4a container if not processing") - return - - with Session(models.engine) as session: - podcast = session.exec( - select(models.Podcast).where(models.Podcast.id == podcast_id) - ).first() - - if podcast is None: - log.error("Failed importing episode, podcast does not exist.") - return - - episode = models.PodcastEpisode( - name=filename.stem, file_size=0, file_hash="", podcast_id=podcast.id - ) - - episode_filename = settings.directory / f"{episode.id}.m4a" - - if move: - log.info("Moving episode to %s...", episode_filename) - shutil.move(filename, episode_filename) - else: - log.info("Copying episode to %s...", episode_filename) - shutil.copyfile(filename, episode_filename) - - probe = ffmpeg.probe(str(episode_filename)) - stream = next( - (stream for stream in probe["streams"] if stream["codec_type"] == "audio"), - None, - ) - - file_hash = hashlib.sha256() - with open(episode_filename, "rb") as f: - for byte_block in iter(lambda: f.read(4096), b""): - file_hash.update(byte_block) - - episode.duration = ( - float(stream["duration"]) - if stream is not None and "duration" in stream - else None - ) - episode.file_hash = file_hash.hexdigest() - episode.file_size = episode_filename.stat().st_size - - session.add(episode) - session.commit() - - log.info("Imported episode as %s", episode.id) - - -def main(): - parser = argparse.ArgumentParser( - prog="import-episode.py", - description="Import an episode", - ) - parser.add_argument("filename") - parser.add_argument("podcast_id") - parser.add_argument("--process", action="store_true") - - args = parser.parse_args() - - import_episode(Path(args.filename), args.podcast_id, args.process) - - -if __name__ == "__main__": - main() diff --git a/scripts/update-pub-date.py b/scripts/update-pub-date.py deleted file mode 100644 index d8c18d2..0000000 --- a/scripts/update-pub-date.py +++ /dev/null @@ -1,50 +0,0 @@ -import argparse -import sys -from datetime import datetime -from pathlib import Path - -import structlog -from sqlmodel import Session, select - -# Add the src directory to the system path -sys.path.append(str(Path(__file__).resolve().parent.parent / "src")) - -import models as models - -log = structlog.get_logger() - - -def update_pub_date(episode_id: str, new_date: str): - with Session(models.engine) as session: - episode = session.exec( - select(models.PodcastEpisode).where(models.PodcastEpisode.id == episode_id) - ).first() - - if episode is None: - log.error("Could not find episode") - return - - episode.publish_date = datetime.fromisoformat(new_date) - assert episode.publish_date.tzinfo is not None, "timezone is required" - - session.add(episode) - session.commit() - - log.info("Updated episode", episode.id) - - -def main(): - parser = argparse.ArgumentParser( - prog="update-pub-date.py", - description="Update an episode publish date", - ) - parser.add_argument("episode_id") - parser.add_argument("new_date") - - args = parser.parse_args() - - update_pub_date(args.episode_id, args.new_date) - - -if __name__ == "__main__": - main() diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..40c5590 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,57 @@ +from typing import Annotated, Any, Dict + +import jwt +import jwt.algorithms +from fastapi import HTTPException, Security, security +from pydantic import BaseModel, Field + +from settings import settings + +jwks_client = jwt.PyJWKClient( + settings.oidc_jwks_url, headers={"User-Agent": "PodcastGenerator"} +) + +oauth2_scheme = security.OAuth2AuthorizationCodeBearer( + authorizationUrl=settings.oidc_authorize_url, tokenUrl=settings.oidc_token_url +) + + +class CurrentUser(BaseModel): + user_id: str = Field() + user_name: str = Field() + + +def user_token( + token_str: Annotated[str, Security(oauth2_scheme)], + required_scopes: security.SecurityScopes, +) -> CurrentUser: + try: + token: Dict[Any, Any] = jwt.decode( + token_str, + jwks_client.get_signing_key_from_jwt(token_str).key, + algorithms=["RS256"], + audience=settings.oidc_permitted_jwt_audiences, + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e + + for scope in required_scopes.scopes: + if scope not in token["scope"]: + raise HTTPException( + status_code=401, + detail="Not enough permissions", + headers={ + "WWW-Authenticate", + f'Bearer scope="{required_scopes.scope_str}"', + }, + ) + + return CurrentUser( + user_id=token.get(settings.oidc_sub_jwt_attribute), + user_name=token.get(settings.oidc_name_jwt_attribute), + ) diff --git a/src/episode_file.py b/src/episode_file.py new file mode 100644 index 0000000..2c0825d --- /dev/null +++ b/src/episode_file.py @@ -0,0 +1,76 @@ +import xml.etree.ElementTree as ET +from datetime import timedelta +from typing import Callable, List, Optional, Tuple + +from fastapi import HTTPException, UploadFile + +from models import PodcastEpisode + + +async def djuced_track_list( + episode: PodcastEpisode, file: UploadFile +) -> Optional[PodcastEpisode]: + root = ET.fromstring(await file.read()) + + # if this doesn't look like a djuced track list xml, return nothing + if root.tag != "recordEvents": + return None + + tracks = [] + + for track in root.iter("track"): + title = track.get("song") + artist = track.get("artist") + + intervals = track.findall("interval") + + # if the title looks like it contains the artist too, overwrite the existing artist + title_segments = title.split(" - ") + if len(title_segments) == 2: + artist, title = title_segments + + for interval in intervals: + tracks.append((float(interval.get("start")), title, artist)) + + # sort by start time + tracks = sorted(tracks, key=lambda x: x[0], reverse=False) + + # update description + track_list_str = "" + + for i, (t, title, artist) in enumerate(tracks): + time = timedelta(seconds=round(t)) + track_list_str += f"{i + 1}. {title} _- {artist} [{time}]_\n" + + episode.description += "\n\n**Track list**\n\n" + track_list_str + return episode + + +# list of file processors +# these are processed in order and only run if the file content type matches the first tuple string +# the second tuple item is the function to run which should return none if the file was not able to be processed, otherwise a mutated episode object +processors: List[ + Tuple[str, Callable[[PodcastEpisode, UploadFile], Optional[PodcastEpisode]]] +] = [("text/xml", djuced_track_list)] + + +async def process_additional_episode_upload( + episode: PodcastEpisode, file: UploadFile +) -> PodcastEpisode: + for processor_content_type, do_process in processors: + if processor_content_type != file.content_type: + continue + + try: + result = await do_process(episode, file) + + if result is not None: + return result + except Exception as e: + print(f"Failed to process using {do_process.__name__}: {e}") + continue + + raise HTTPException( + status_code=400, + detail=f"Unable to process additional episode upload ({file.content_type})", + ) diff --git a/src/helpers.py b/src/helpers.py new file mode 100644 index 0000000..dab7db2 --- /dev/null +++ b/src/helpers.py @@ -0,0 +1,15 @@ +from typing import Optional + +import bleach +import markdown + + +def render_markdown(content: Optional[str]) -> Optional[str]: + if content is None or content.strip() == "": + return None + + return bleach.clean( + markdown.markdown(content), + tags=["p", "a", "code", "pre", "blockquote", "ol", "li", "ul", "strong", "em"], + attributes={"a": ["href"]}, + ) diff --git a/src/main.py b/src/main.py index fab5122..3f4cc57 100644 --- a/src/main.py +++ b/src/main.py @@ -1,29 +1,26 @@ import urllib.parse import uuid -from contextlib import asynccontextmanager from datetime import timedelta, timezone from pathlib import Path from typing import Annotated, Any, Generator, Optional -import markdown import podgen import structlog -from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile +from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse +from fastapi.routing import APIRoute +from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from PIL import Image -from sqlmodel import Session, and_, or_, select +from sqlmodel import Session, and_, desc, select import models +from auth import CurrentUser, user_token +from episode_file import process_additional_episode_upload +from helpers import render_markdown from process import AudioProcessor -from settings import settings - - -@asynccontextmanager -async def lifespan(app: FastAPI): - models.setup_db(models.engine) - yield +from settings import AppConfig, settings def get_session() -> Generator[Session, Any, None]: @@ -31,26 +28,26 @@ def get_session() -> Generator[Session, Any, None]: yield session -def handle_user_auth(request: Request) -> tuple[str, str]: - if ( - settings.forward_auth_name_header is None - or settings.forward_auth_uid_header is None - ): - return ("default", "Admin") - - return ( - request.headers.get(settings.forward_auth_uid_header, "default"), - request.headers.get(settings.forward_auth_name_header, "Admin"), - ) +def use_route_names_as_operation_ids(app: FastAPI) -> None: + for route in app.routes: + if isinstance(route, APIRoute): + route.operation_id = route.name SessionDep = Annotated[Session, Depends(get_session)] -AuthDep = Annotated[tuple[str, str], Depends(handle_user_auth)] +AuthDep = Annotated[CurrentUser, Depends(user_token)] log = structlog.get_logger() -app = FastAPI(lifespan=lifespan) +app = FastAPI( + title="Podcast Generator", + swagger_ui_init_oauth={ + "appName": "PodcastGenerator", + "clientId": settings.oidc_client_id, + "usePkceWithAuthorizationCodeGrant": True, + }, +) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] ) @@ -59,103 +56,137 @@ templates = Jinja2Templates(directory="src/templates") audio_processor = AudioProcessor() audio_processor.start_processing() +app.mount("/static", StaticFiles(directory="dist/static"), name="static") -@app.get("/admin") -def admin_list_podcasts(session: SessionDep, request: Request, user: AuthDep): + +@app.get("/api/config", response_model=AppConfig) +def get_app_config(): + return AppConfig( + oidc_authority=settings.oidc_authority, + oidc_client_id=settings.oidc_client_id, + oidc_scopes=settings.oidc_scopes, + ) + + +@app.get("/api/user", response_model=CurrentUser) +def read_user(auth: AuthDep): + return auth + + +@app.post("/api/podcasts", response_model=models.PodcastPublic) +def create_podcast(auth: AuthDep, session: SessionDep, podcast: models.PodcastCreate): + db_podcast = models.Podcast.model_validate(podcast) + db_podcast.owner_id = auth.user_id + session.add(db_podcast) + session.commit() + session.refresh(db_podcast) + return db_podcast + + +@app.get("/api/podcasts", response_model=list[models.PodcastPublic]) +def read_podcasts(auth: AuthDep, session: SessionDep): podcasts = session.exec( - select(models.Podcast).where( - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ) - ) + select(models.Podcast).where(models.Podcast.owner_id == auth.user_id) ).all() - - return templates.TemplateResponse( - request=request, - name="admin_feeds.html.j2", - context={ - "podcasts": podcasts, - "user_name": user[1], - }, - ) + return podcasts -@app.get("/admin/new") -def admin_create_podcast(request: Request): - return templates.TemplateResponse( - request=request, - name="admin_new.html.j2", - ) +@app.get("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic) +def read_podcast(session: SessionDep, podcast_id: str): + podcast = session.get(models.Podcast, podcast_id) + if not podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + return podcast -@app.post("/admin/new") -def admin_create_podcast_post( +@app.patch("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic) +def update_podcast( + auth: AuthDep, session: SessionDep, - request: Request, - user: AuthDep, - name: Annotated[str, Form()], + podcast_id: str, + podcast: models.PodcastUpdate = None, ): - if name.strip() == "": - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=400, - context={ - "title": "Invalid entry", - "description": "Name must not be empty", - }, + db_podcast = session.get(models.Podcast, podcast_id) + if not db_podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + + if db_podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to update this podcast" ) - podcast = models.Podcast(name=name, description=name, owner_id=user[0]) + podcast_data = podcast.model_dump(exclude_unset=True) + db_podcast.sqlmodel_update(podcast_data) + + session.add(db_podcast) + session.commit() + session.refresh(db_podcast) + return db_podcast + + +@app.post("/api/podcasts/{podcast_id}/image", response_model=models.PodcastPublic) +def update_podcast_image( + auth: AuthDep, + session: SessionDep, + podcast_id: str, + image: UploadFile, +): + podcast = session.get(models.Podcast, podcast_id) + if not podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + + if podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to update this podcast" + ) + + if image is not None and image.size > 0: + if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")): + return HTTPException( + 400, detail="The uploaded podcast image must be a jpg or png" + ) + + im = Image.open(image.file) + + if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000: + return HTTPException( + 400, + detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size", + ) + + if im.mode != "RGB": + return HTTPException( + 400, detail="The uploaded podcast image must be in RGB format" + ) + + if podcast.image_filename is not None: + (settings.directory / podcast.image_filename).unlink(missing_ok=True) + + filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix + im.save(settings.directory / filename) + podcast.image_filename = filename session.add(podcast) session.commit() - - return RedirectResponse(f"/admin/{podcast.id}", status_code=303) + session.refresh(podcast) + return podcast -@app.get("/admin/{podcast_id}") -def admin_list_podcast( - session: SessionDep, request: Request, podcast_id: str, user: AuthDep -): - podcast = session.exec( - select(models.Podcast).where( - and_( - models.Podcast.id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) - ).first() +@app.delete("/api/podcasts/{podcast_id}") +def delete_podcast(auth: AuthDep, session: SessionDep, podcast_id: str): + podcast = session.get(models.Podcast, podcast_id) - if podcast is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=400, - context={ - "title": "Not found", - "description": "The podcast was not found.", - }, + if not podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + + if podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to delete this podcast" ) - episodes = podcast.episodes - episodes.sort(key=lambda e: e.publish_date, reverse=True) - - return templates.TemplateResponse( - request=request, - name="admin_feed.html.j2", - context={ - "podcast": podcast, - "episodes": episodes, - "feed_uri": urllib.parse.urljoin( - str(request.base_url), f"{podcast.id}.xml" - ), - }, - ) + session.delete(podcast) + session.commit() + return {"ok": True} def finish_processing( @@ -185,13 +216,13 @@ def finish_processing( session.commit() -@app.post("/admin/{podcast_id}/upload") +@app.post("/api/podcasts/{podcast_id}/episodes") async def admin_upload_episode( + auth: AuthDep, session: SessionDep, request: Request, podcast_id: str, file: UploadFile, - user: AuthDep, ): file_id = request.headers.get("uploader-file-id") chunks_total = int(request.headers.get("uploader-chunks-total")) @@ -204,20 +235,18 @@ async def admin_upload_episode( file_id = "".join(c for c in file_id if c.isalnum()).strip() podcast = session.exec( - select(models.Podcast).where( - and_( - models.Podcast.id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) + select(models.Podcast).where(models.Podcast.id == podcast_id) ).first() if podcast is None: raise HTTPException(status_code=404, detail="Podcast not found") + if podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, + detail="You do not have permission to add episodes to this podcast", + ) + is_last = (chunk_number + 1) == chunks_total file_name = f"{file_id}_{chunk_number}" @@ -253,256 +282,137 @@ async def admin_upload_episode( ), ) - return JSONResponse({"message": "File Uploaded"}, status_code=200) + return {"message": "File Uploaded"} - return JSONResponse({"message": "Chunk Uploaded"}, status_code=200) + return {"message": "Chunk Uploaded"} -@app.get("/admin/{podcast_id}/{episode_id}/delete") -def admin_delete_episode( +@app.get( + "/api/podcasts/{podcast_id}/episodes", + response_model=list[models.PodcastEpisodePublic], +) +def read_episodes(session: SessionDep, podcast_id: str): + episodes = session.exec( + select(models.PodcastEpisode) + .where(models.PodcastEpisode.podcast_id == podcast_id) + .order_by(desc(models.PodcastEpisode.publish_date)) + ).all() + return episodes + + +@app.get( + "/api/podcasts/{podcast_id}/episodes/{episode_id}", + response_model=models.PodcastEpisodePublic, +) +def read_episode(session: SessionDep, podcast_id: str, episode_id: str): + episode = session.exec( + select(models.PodcastEpisode).where( + and_( + models.PodcastEpisode.id == episode_id, + models.PodcastEpisode.podcast_id == podcast_id, + ) + ) + ).first() + if not episode: + raise HTTPException(status_code=404, detail="Episode not found") + return episode + + +@app.patch( + "/api/podcasts/{podcast_id}/episodes/{episode_id}", + response_model=models.PodcastEpisodePublic, +) +def update_episode( + auth: AuthDep, session: SessionDep, - request: Request, podcast_id: str, episode_id: str, - user: AuthDep, + episode: models.PodcastEpisodeUpdate, +): + db_episode = session.exec( + select(models.PodcastEpisode).where( + and_( + models.PodcastEpisode.id == episode_id, + models.PodcastEpisode.podcast_id == podcast_id, + ) + ) + ).first() + + if not db_episode: + raise HTTPException(status_code=404, detail="Episode not found") + + if db_episode.podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to update this episode" + ) + + episode_data = episode.model_dump(exclude_unset=True) + db_episode.sqlmodel_update(episode_data) + session.add(db_episode) + session.commit() + session.refresh(db_episode) + return db_episode + + +@app.post( + "/api/podcasts/{podcast_id}/episodes/{episode_id}", + response_model=models.PodcastEpisodePublic, +) +async def episode_additional_upload( + auth: AuthDep, + session: SessionDep, + podcast_id: str, + episode_id: str, + file: UploadFile, +): + db_episode = session.exec( + select(models.PodcastEpisode).where( + and_( + models.PodcastEpisode.id == episode_id, + models.PodcastEpisode.podcast_id == podcast_id, + ) + ) + ).first() + + if not db_episode: + raise HTTPException(status_code=404, detail="Episode not found") + + if db_episode.podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to update this episode" + ) + + new_episode = await process_additional_episode_upload(db_episode, file) + + session.add(new_episode) + session.commit() + session.refresh(new_episode) + return new_episode + + +@app.delete("/api/podcasts/{podcast_id}/episodes/{episode_id}") +def delete_episode( + auth: AuthDep, session: SessionDep, podcast_id: str, episode_id: str ): episode = session.exec( select(models.PodcastEpisode).where( and_( models.PodcastEpisode.id == episode_id, models.PodcastEpisode.podcast_id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), ) ) ).first() + if not episode: + raise HTTPException(status_code=404, detail="Episode not found") - if episode is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=404, - context={ - "title": "Not found", - "description": "The episode or podcast was not found.", - }, + if episode.podcast.owner_id != auth.user_id: + raise HTTPException( + status_code=403, detail="You do not have permission to delete this episode" ) - (settings.directory / f"{episode_id}.m4a").unlink() session.delete(episode) session.commit() - - return RedirectResponse(f"/admin/{podcast_id}", status_code=303) - - -@app.get("/admin/{podcast_id}/{episode_id}/edit") -def admin_edit_episode( - session: SessionDep, - request: Request, - podcast_id: str, - episode_id: str, - user: AuthDep, -): - episode = session.exec( - select(models.PodcastEpisode).where( - and_( - models.PodcastEpisode.id == episode_id, - models.PodcastEpisode.podcast_id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) - ).first() - - if episode is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=404, - context={ - "title": "Not found", - "description": "The episode or podcast was not found.", - }, - ) - - return templates.TemplateResponse( - request=request, - name="admin_episode_edit.html.j2", - context={"episode": episode}, - ) - - -@app.post("/admin/{podcast_id}/{episode_id}/edit") -def admin_edit_episode_post( - session: SessionDep, - request: Request, - podcast_id: str, - episode_id: str, - user: AuthDep, - name: Annotated[str, Form()], - description: Annotated[str, Form()], -): - episode = session.exec( - select(models.PodcastEpisode).where( - and_( - models.PodcastEpisode.id == episode_id, - models.PodcastEpisode.podcast_id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) - ).first() - - if episode is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=404, - context={ - "title": "Not found", - "description": "The episode or podcast was not found.", - }, - ) - - if name.strip() != "": - episode.name = name - - if description.strip() != "": - episode.description = description - else: - episode.description = None - - session.add(episode) - session.commit() - - return RedirectResponse(f"/admin/{podcast_id}", status_code=303) - - -@app.get("/admin/{podcast_id}/edit") -def admin_edit_podcast( - session: SessionDep, request: Request, user: AuthDep, podcast_id: str -): - podcast = session.exec( - select(models.Podcast).where( - and_( - models.Podcast.id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) - ).first() - - if podcast is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=404, - context={ - "title": "Not found", - "description": "The podcast was not found.", - }, - ) - - return templates.TemplateResponse( - request=request, - name="admin_feed_edit.html.j2", - context={"podcast": podcast}, - ) - - -@app.post("/admin/{podcast_id}/edit") -def admin_edit_podcast_post( - session: SessionDep, - request: Request, - podcast_id: str, - user: AuthDep, - name: Annotated[str, Form()], - description: Annotated[str, Form()], - image: Optional[UploadFile] = None, -): - podcast = session.exec( - select(models.Podcast).where( - and_( - models.Podcast.id == podcast_id, - or_( - models.Podcast.owner_id == user[0], - models.Podcast.owner_id == None, - ), - ) - ) - ).first() - - if podcast is None: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=404, - context={ - "title": "Not found", - "description": "The podcast was not found.", - }, - ) - - if name.strip() != "": - podcast.name = name - - podcast.description = description - - if image is not None and image.size > 0: - if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")): - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=400, - context={ - "title": "Invalid entry", - "description": "The uploaded podcast image must be a jpg or png.", - }, - ) - - im = Image.open(image.file) - - if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000: - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=400, - context={ - "title": "Invalid entry", - "description": "The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size.", - }, - ) - - if im.mode != "RGB": - return templates.TemplateResponse( - request, - name="admin_error.html.j2", - status_code=400, - context={ - "title": "Invalid entry", - "description": "The uploaded podcast image must be in RGB format.", - }, - ) - - if podcast.image_filename is not None: - (settings.directory / podcast.image_filename).unlink(missing_ok=True) - - filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix - im.save(settings.directory / filename) - podcast.image_filename = filename - - session.add(podcast) - session.commit() - - return RedirectResponse(f"/admin/{podcast_id}", status_code=303) + return {"ok": True} @app.get("/{podcast_id}.xml") @@ -543,9 +453,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str): if episode.duration is not None else None, ), - long_summary=markdown.markdown(episode.description) - if episode.description is not None - else None, + long_summary=render_markdown(episode.description), ) ) @@ -582,3 +490,11 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str): return FileResponse(settings.directory / podcast.image_filename) return HTTPException(status_code=404, detail="File not found") + + +@app.get("/{catchall:path}") +async def serve_app(catchall: str): + return FileResponse("dist/index.html") + + +use_route_names_as_operation_ids(app) diff --git a/src/models.py b/src/models.py index 8de1d7e..a8d191f 100644 --- a/src/models.py +++ b/src/models.py @@ -1,72 +1,92 @@ -import json from datetime import datetime, timezone -from typing import Optional +from typing import List, Optional import nanoid +from pydantic import ConfigDict, computed_field from sqlalchemy import Engine -from sqlmodel import Field, Relationship, Session, SQLModel, create_engine +from sqlmodel import Field, Relationship, SQLModel, create_engine +from helpers import render_markdown from settings import settings -class Podcast(SQLModel, table=True): - id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate()) +class PodcastBase(SQLModel): name: str description: str explicit: bool = Field(default=True) - image_filename: Optional[str] = Field(default=None) - owner_id: Optional[str] = Field(default=None) - - episodes: list["PodcastEpisode"] = Relationship(back_populates="podcast") -class PodcastEpisode(SQLModel, table=True): +class Podcast(PodcastBase, table=True): id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate()) + owner_id: Optional[str] = Field(default=None) + episodes: List["PodcastEpisode"] = Relationship( + back_populates="podcast", cascade_delete=True + ) + image_filename: Optional[str] = Field(default=None) + + +class PodcastPublic(PodcastBase): + id: str + image_filename: Optional[str] = Field(default=None) + + +class PodcastCreate(PodcastBase): + name: str = Field(min_length=1) + description: str = Field(min_length=1, default_factory=lambda data: data["name"]) + + model_config = ConfigDict(str_strip_whitespace=True) + + +class PodcastUpdate(SQLModel): + name: Optional[str] = Field(min_length=1) + description: Optional[str] = Field(min_length=1) + explicit: Optional[bool] = Field(default=True) + + model_config = ConfigDict(str_strip_whitespace=True) + + +class PodcastEpisodeBase(SQLModel): name: str duration: Optional[float] = Field(default=None) description: Optional[str] = Field(default=None) file_hash: str file_size: int - publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + publish_date: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), nullable=False + ) + +class PodcastEpisode(PodcastEpisodeBase, table=True): + id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate()) podcast_id: str = Field(foreign_key="podcast.id") podcast: Podcast = Relationship(back_populates="episodes") +class PodcastEpisodePublic(PodcastEpisodeBase): + id: str + podcast_id: str + + @computed_field + def description_html(self) -> Optional[str]: + return render_markdown(self.description) + + +class PodcastEpisodeCreate(PodcastEpisodeBase): + podcast_id: str + + model_config = ConfigDict(str_strip_whitespace=True) + + +class PodcastEpisodeUpdate(SQLModel): + name: Optional[str] = Field(min_length=1) + description: Optional[str] = Field(min_length=1) + publish_date: Optional[datetime] = Field(default=None, nullable=True) + + model_config = ConfigDict(str_strip_whitespace=True) + + def setup_db(engine: Engine): SQLModel.metadata.create_all(engine) - # try and migrate old data - old_data = settings.directory / "data.json" - if old_data.is_file(): - try: - session = Session(engine) - - with open(old_data, "r") as f: - data = json.load(f) - - for id, item in data["podcasts"].items(): - podcast = Podcast( - id=id, - name=item.get("name"), - description=item.get("description"), - explicit=item.get("explicit"), - image_filename=item.get("image_filename"), - ) - session.add(podcast) - session.commit() - - for episode in item["episodes"]: - ep = PodcastEpisode.model_validate( - {**episode, "podcast_id": podcast.id, "id": nanoid.generate()} - ) - session.add(ep) - - session.commit() - - old_data.unlink() - except Exception as ex: - print("Failed to migrate old data", ex) - engine = create_engine(f"sqlite:///{settings.directory / 'data.db'}") diff --git a/src/settings.py b/src/settings.py index 709e3b8..3aa1f3e 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,17 +1,32 @@ from pathlib import Path -from typing import Optional +from typing import List -from pydantic import Field +from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): directory: Path = Field(default=Path.cwd() / "data") uploads_directory: Path = Field(default=Path.cwd() / "uploads") - forward_auth_name_header: Optional[str] = Field(default=None) - forward_auth_uid_header: Optional[str] = Field(default=None) + oidc_authorize_url: str = Field() + oidc_token_url: str = Field() + oidc_jwks_url: str = Field() + oidc_permitted_jwt_audiences: List[str] = Field() + oidc_client_id: str = Field() + oidc_sub_jwt_attribute: str = Field(default="sub") + oidc_name_jwt_attribute: str = Field(default="name") + oidc_scopes: List[str] = Field( + default=["openid", "email", "profile", "offline_access"] + ) + oidc_authority: str = Field() model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_") +class AppConfig(BaseModel): + oidc_authority: str = Field() + oidc_client_id: str = Field() + oidc_scopes: List[str] = Field() + + settings = Settings() diff --git a/src/templates/admin_episode_edit.html.j2 b/src/templates/admin_episode_edit.html.j2 deleted file mode 100644 index a6d93da..0000000 --- a/src/templates/admin_episode_edit.html.j2 +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -

{{ episode.name }}

-
-
- - -
- - -
-{% endblock %} diff --git a/src/templates/admin_error.html.j2 b/src/templates/admin_error.html.j2 deleted file mode 100644 index 5d588b6..0000000 --- a/src/templates/admin_error.html.j2 +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -

{{ title }}

-

{{ description }}

-

Go Back

-{% endblock %} diff --git a/src/templates/admin_feed.html.j2 b/src/templates/admin_feed.html.j2 deleted file mode 100644 index f5fc71f..0000000 --- a/src/templates/admin_feed.html.j2 +++ /dev/null @@ -1,116 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -{% if podcast.image_filename %} - -

-{% endif %} -

{{ podcast.name }}

-

- Actions: - Edit -

-

- Description: - {{ podcast.description }} -

-

- Subscribe: -

{{ feed_uri }}
-For Apple Podcasts, open the app and click on the Library tab along the bottom. Select the ellipsis in the top - right and hit "Follow a Show by URL...". For other apps, look for an option allowing you to add a show by - URL. -

- - -

Upload Episode

-
- - - -

-
-

Episodes

- - - - - - - - - - - {% for episode in episodes %} - - - - - - - {% endfor %} - -
NamePublishedLengthActions
{{ episode.name }}{{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }} - {% if episode.duration %} - {{ (episode.duration / 60) | round | int }}min - {% else %} - ? - {% endif %} - - Delete - Edit -
- - -{% endblock %} diff --git a/src/templates/admin_feed_edit.html.j2 b/src/templates/admin_feed_edit.html.j2 deleted file mode 100644 index 7940f6d..0000000 --- a/src/templates/admin_feed_edit.html.j2 +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -

{{ podcast.name }}

-
-
- - - -
- - -
-{% endblock %} diff --git a/src/templates/admin_feeds.html.j2 b/src/templates/admin_feeds.html.j2 deleted file mode 100644 index 1ce44b2..0000000 --- a/src/templates/admin_feeds.html.j2 +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -

Hello {{ user_name }}!

-

My Podcasts

-

- Actions: Create -

- -{% endblock %} diff --git a/src/templates/admin_layout.html.j2 b/src/templates/admin_layout.html.j2 deleted file mode 100644 index 0eea34b..0000000 --- a/src/templates/admin_layout.html.j2 +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'layout.html.j2' %} -{% block content %} - -{% endblock %} diff --git a/src/templates/admin_new.html.j2 b/src/templates/admin_new.html.j2 deleted file mode 100644 index 428e0d2..0000000 --- a/src/templates/admin_new.html.j2 +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'admin_layout.html.j2' %} -{% block content %} -{{ super() }} -

New Podcast

-
-
- -
- - -
-{% endblock %} diff --git a/src/templates/layout.html.j2 b/src/templates/layout.html.j2 deleted file mode 100644 index 5cef0c8..0000000 --- a/src/templates/layout.html.j2 +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - Podcast Server - - - -
- {% block content %}{% endblock %} -
- - - diff --git a/uv.lock b/uv.lock index 288fe3c..5e3e4d4 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, ] +[[package]] +name = "alembic" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/09/f844822e4e847a3f0bd41797f93c4674cd4d2462a3f6c459aa528cdf786e/alembic-1.14.1.tar.gz", hash = "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213", size = 1918219 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/7e/ac0991d1745f7d755fc1cd381b3990a45b404b4d008fc75e2a983516fbfe/alembic-1.14.1-py3-none-any.whl", hash = "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", size = 233565 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -32,6 +46,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 }, +] + [[package]] name = "certifi" version = "2024.12.14" @@ -41,6 +67,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -96,6 +144,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 }, ] +[[package]] +name = "cryptography" +version = "44.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, + { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, + { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, + { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, + { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, + { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, + { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, + { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, + { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, + { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, + { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, + { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, + { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, + { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, + { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, + { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, + { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, + { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, + { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, +] + [[package]] name = "dateutils" version = "0.6.12" @@ -304,6 +383,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + [[package]] name = "jinja2" version = "3.1.5" @@ -341,6 +429,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 }, ] +[[package]] +name = "mako" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569 }, +] + [[package]] name = "markdown" version = "3.7" @@ -441,35 +541,49 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, + { name = "alembic" }, + { name = "bleach" }, + { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, { name = "ffmpeg-normalize" }, { name = "ffmpeg-python" }, + { name = "itsdangerous" }, { name = "markdown" }, { name = "nanoid" }, { name = "pillow" }, { name = "podgen" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-multipart" }, + { name = "requests" }, { name = "sqlmodel" }, { name = "structlog" }, + { name = "uvicorn" }, ] [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "alembic", specifier = ">=1.14.1" }, + { name = "bleach", specifier = ">=6.2.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" }, { name = "ffmpeg-normalize", specifier = ">=1.31.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "markdown", specifier = ">=3.7" }, { name = "nanoid", specifier = ">=2.0.0" }, { name = "pillow", specifier = ">=11.1.0" }, { name = "podgen", specifier = ">=1.1.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "pydantic-settings", specifier = ">=2.7.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "requests", specifier = ">=2.32.3" }, { name = "sqlmodel", specifier = ">=0.0.22" }, { name = "structlog", specifier = ">=24.4.0" }, + { name = "uvicorn", specifier = ">=0.34.0" }, ] [[package]] @@ -489,6 +603,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/f9/c78c6c4ab0474dc095c8037e18484e02149c33f9a0870bdf1cc6ce7eb339/podgen-1.1.0-py2.py3-none-any.whl", hash = "sha256:0ee7b262c131773e5973a63a56a5ee96375ea522b1c30ccd4ec4c35e0c1bb2c6", size = 38268 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pydantic" version = "2.10.5" @@ -550,6 +673,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -845,6 +977,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/da/725f97a8b1b4e7b3e4331cce3ef921b12568af3af403b9f0f61ede036898/watchfiles-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:160eff7d1267d7b025e983ca8460e8cc67b328284967cbe29c05f3c3163711a3", size = 285246 }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + [[package]] name = "websockets" version = "14.1"