commit 244cdbedd7214ce604f3612e9091f4ca8c7e1fb0 Author: Nathan FONTEYNE Date: Wed Jul 8 11:05:21 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..93d0213 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +NODE_ENV=production +PORT=3000 + +# Postgres +DATABASE_URL=postgres://octane:changeme@postgres:5432/octane +POSTGRES_PASSWORD=changeme + +# Sessions +SESSION_SECRET=change-me-to-a-long-random-string + +# Authentik OIDC +AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/octane-website/ +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://octane.example.com/auth/callback +ADMIN_GROUP_NAME=octane-admins + +# Docker networking (must match the network created by your existing Authentik compose stack) +AUTHENTIK_NETWORK_NAME=authentik_default +APP_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03c5ccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e711b8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["node", "src/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c925563 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + app: + build: . + restart: unless-stopped + env_file: .env + ports: + - "${APP_PORT:-3000}:3000" + depends_on: + - postgres + networks: + - default + - authentik + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: octane + POSTGRES_USER: octane + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - default + +networks: + default: + authentik: + external: true + name: ${AUTHENTIK_NETWORK_NAME:-authentik_default} + +volumes: + pgdata: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c21255 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1087 @@ +{ + "name": "octane-website", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "octane-website", + "version": "1.0.0", + "dependencies": { + "connect-pg-simple": "^10.0.0", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "express-session": "^1.18.1", + "openid-client": "^6.1.7", + "pg": "^8.13.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "license": "MIT", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c2635d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "octane-website", + "version": "1.0.0", + "description": "Outil interne de gestion du repertoire, suggestions et setlists du groupe", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "migrate": "node src/db/migrate.js" + }, + "dependencies": { + "connect-pg-simple": "^10.0.0", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "express-session": "^1.18.1", + "openid-client": "^6.1.7", + "pg": "^8.13.1" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..d0a7f50 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,254 @@ +:root { + --bg: #f7f7f9; + --surface: #ffffff; + --text: #1c1c1e; + --muted: #6b6b70; + --border: #e2e2e6; + --accent: #3457d5; + --accent-contrast: #ffffff; + --danger: #c0392b; + --success: #1e8e3e; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #121214; + --surface: #1c1c1f; + --text: #ececef; + --muted: #9a9aa2; + --border: #2e2e33; + --accent: #7189e8; + --accent-contrast: #0d0d0f; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +header.app-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1.5rem; +} + +nav#main-nav { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.nav-links a { + margin-right: 1rem; + color: var(--muted); + text-decoration: none; + font-weight: 500; +} + +.nav-links a.active, .nav-links a:hover { + color: var(--accent); +} + +.nav-user { + display: flex; + align-items: center; + gap: 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.nav-user a { + color: var(--accent); + text-decoration: none; +} + +.badge { + background: var(--accent); + color: var(--accent-contrast); + border-radius: 999px; + padding: 0.1rem 0.5rem; + font-size: 0.75rem; +} + +main { + max-width: 900px; + margin: 0 auto; + padding: 1.5rem; +} + +h1 { font-size: 1.5rem; } +h2 { font-size: 1.15rem; margin-top: 2rem; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + margin-bottom: 0.75rem; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; +} + +.card-title { font-weight: 600; } +.card-subtitle { color: var(--muted); font-size: 0.9rem; } + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.tag { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.15rem 0.5rem; + font-size: 0.8rem; +} + +.tag a { color: var(--accent); text-decoration: none; } + +.empty { + color: var(--muted); + font-style: italic; +} + +form.inline-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; + align-items: flex-end; +} + +form.inline-form label { + display: flex; + flex-direction: column; + font-size: 0.8rem; + color: var(--muted); + gap: 0.25rem; +} + +input, select, textarea { + font: inherit; + padding: 0.4rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); +} + +button { + font: inherit; + padding: 0.45rem 0.9rem; + border: none; + border-radius: 6px; + background: var(--accent); + color: var(--accent-contrast); + cursor: pointer; +} + +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +button.danger { + background: var(--danger); + color: #fff; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vote-tally { + display: flex; + gap: 1rem; + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.vote-tally .approve { color: var(--success); } +.vote-tally .reject { color: var(--danger); } + +.vote-list { + margin-top: 0.5rem; + border-top: 1px solid var(--border); + padding-top: 0.5rem; +} + +.vote-item { + font-size: 0.85rem; + margin-bottom: 0.35rem; +} + +.vote-item .voter { font-weight: 600; } + +.youtube-embed { + margin-top: 0.75rem; + aspect-ratio: 16 / 9; + width: 100%; + max-width: 480px; +} + +.youtube-embed iframe { + width: 100%; + height: 100%; + border: 0; + border-radius: 8px; +} + +.setlist-section h3 { + margin-bottom: 0.4rem; +} + +ol.setlist { + padding-left: 1.25rem; +} + +ol.setlist li { + margin-bottom: 0.5rem; +} + +.note { + color: var(--muted); + font-size: 0.85rem; + display: block; +} + +.error-banner { + background: #fdecea; + color: var(--danger); + border: 1px solid var(--danger); + border-radius: 8px; + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; +} + +@media (prefers-color-scheme: dark) { + .error-banner { + background: #3a1f1c; + } +} + +a.back-link { + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; +} diff --git a/public/history-detail.html b/public/history-detail.html new file mode 100644 index 0000000..ecc2839 --- /dev/null +++ b/public/history-detail.html @@ -0,0 +1,24 @@ + + + + + +Détail du concert — Octane + + + +
+ +
+
+ ← Retour à l'historique +
+
Chargement…
+
+ + + + + + + diff --git a/public/history.html b/public/history.html new file mode 100644 index 0000000..fbecc9f --- /dev/null +++ b/public/history.html @@ -0,0 +1,24 @@ + + + + + +Historique des concerts — Octane + + + +
+ +
+
+

Historique des concerts

+
+
+
+ + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cbeb3c1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,36 @@ + + + + + +Répertoire — Octane + + + +
+ +
+
+

Répertoire

+
+ + + +

Morceaux travaillés

+
+
+ + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..9fa839b --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,24 @@ +async function apiFetch(path, options = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (res.status === 401) { + window.location.href = '/auth/login?returnTo=' + encodeURIComponent(window.location.pathname); + return new Promise(() => {}); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || body.error || `Request failed: ${res.status}`); + } + if (res.status === 204) return null; + return res.json(); +} + +const api = { + get: (path) => apiFetch(path), + post: (path, data) => apiFetch(path, { method: 'POST', body: JSON.stringify(data) }), + patch: (path, data) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(data) }), + put: (path, data) => apiFetch(path, { method: 'PUT', body: JSON.stringify(data) }), + del: (path) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/public/js/history-detail.js b/public/js/history-detail.js new file mode 100644 index 0000000..50eb5f4 --- /dev/null +++ b/public/js/history-detail.js @@ -0,0 +1,42 @@ +function formatDate(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + await initNav('history'); + const params = new URLSearchParams(window.location.search); + const id = params.get('id'); + const container = document.getElementById('content'); + if (!id) { + container.innerHTML = '

Concert introuvable.

'; + return; + } + try { + const setlist = await api.get(`/api/setlists/${id}`); + const main = setlist.songs.filter((s) => !s.is_encore); + const encore = setlist.songs.filter((s) => s.is_encore); + container.innerHTML = ` +

${escapeHtml(setlist.name || 'Concert')}

+

${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}

+
+

Setlist

+ ${main.length + ? `
    ${main.map((s) => `
  1. ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  2. `).join('')}
` + : '

Aucun morceau enregistré.

'} +
+
+

Rappel

+ ${encore.length + ? `
    ${encore.map((s) => `
  1. ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  2. `).join('')}
` + : '

Aucun rappel enregistré.

'} +
+ `; + } catch (err) { + showError(err.message); + } +})(); diff --git a/public/js/history.js b/public/js/history.js new file mode 100644 index 0000000..17d1b3c --- /dev/null +++ b/public/js/history.js @@ -0,0 +1,29 @@ +function formatDate(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function concertTemplate(c) { + return ` +
+ +
${escapeHtml(c.name || 'Concert')}
+
${escapeHtml(c.venue || '')} · ${formatDate(c.concert_date)}
+
+
+ `; +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + await initNav('history'); + try { + const history = await api.get('/api/setlists/history'); + renderList(document.getElementById('history-list'), history, concertTemplate, 'Aucun concert passé enregistré.'); + } catch (err) { + showError(err.message); + } +})(); diff --git a/public/js/nav.js b/public/js/nav.js new file mode 100644 index 0000000..82bcf97 --- /dev/null +++ b/public/js/nav.js @@ -0,0 +1,17 @@ +async function initNav(activePage) { + const me = await api.get('/api/users/me'); + const nav = document.getElementById('main-nav'); + nav.innerHTML = ` + + + `; + return me; +} diff --git a/public/js/render.js b/public/js/render.js new file mode 100644 index 0000000..9bac909 --- /dev/null +++ b/public/js/render.js @@ -0,0 +1,30 @@ +function renderList(container, items, templateFn, emptyMessage) { + if (!items.length) { + container.innerHTML = `

${escapeHtml(emptyMessage || 'Rien à afficher pour le moment.')}

`; + return; + } + container.innerHTML = items.map(templateFn).join(''); +} + +function escapeHtml(str) { + return String(str ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function youtubeEmbedUrl(url) { + try { + const parsed = new URL(url); + let videoId = null; + if (parsed.hostname === 'youtu.be') { + videoId = parsed.pathname.slice(1); + } else if (parsed.pathname === '/watch') { + videoId = parsed.searchParams.get('v'); + } else if (parsed.pathname.startsWith('/embed/')) { + videoId = parsed.pathname.split('/embed/')[1]; + } + return videoId ? `https://www.youtube.com/embed/${videoId}` : null; + } catch { + return null; + } +} diff --git a/public/js/repertoire.js b/public/js/repertoire.js new file mode 100644 index 0000000..e7567a2 --- /dev/null +++ b/public/js/repertoire.js @@ -0,0 +1,137 @@ +let me = null; +let instruments = []; +let songs = []; +const expanded = new Set(); + +async function loadInstruments() { + instruments = await api.get('/api/instruments'); +} + +async function loadSongs() { + songs = await api.get('/api/songs'); + renderSongs(); +} + +function instrumentOptions() { + return instruments.map((i) => ``).join(''); +} + +function songCardTemplate(song) { + const isOpen = expanded.has(song.id); + return ` +
+
+
+
${escapeHtml(song.title)}
+
${escapeHtml(song.artist)}
+
+ +
+ ${song.notes ? `

${escapeHtml(song.notes)}

` : ''} +
+

Chargement…

+ ${me && me.isAdmin ? ` +
+ + + + +
` : ''} +
+
+ `; +} + +function renderSongs() { + const container = document.getElementById('songs-list'); + renderList(container, songs, songCardTemplate, 'Aucun morceau au répertoire pour le moment.'); + document.querySelectorAll('.toggle-tutorials').forEach((btn) => { + btn.addEventListener('click', () => onToggleTutorials(parseInt(btn.dataset.id, 10))); + }); + document.querySelectorAll('.add-tutorial-form').forEach((form) => { + form.addEventListener('submit', onAddTutorial); + }); +} + +async function onToggleTutorials(songId) { + if (expanded.has(songId)) { + expanded.delete(songId); + } else { + expanded.add(songId); + } + renderSongs(); + if (expanded.has(songId)) { + await loadTutorials(songId); + } +} + +async function loadTutorials(songId) { + try { + const detail = await api.get(`/api/songs/${songId}`); + const container = document.querySelector(`[data-tutorials-for="${songId}"]`); + if (!container) return; + if (!detail.tutorials.length) { + container.innerHTML = '

Aucun lien pour le moment.

'; + return; + } + container.innerHTML = detail.tutorials + .map( + (t) => `${escapeHtml(t.instrument_name)}: ${escapeHtml(t.label || t.url)}` + ) + .join(''); + } catch (err) { + showError(err.message); + } +} + +async function onAddTutorial(e) { + e.preventDefault(); + const form = e.target; + const songId = parseInt(form.dataset.songId, 10); + const instrumentId = parseInt(form.instrumentId.value, 10); + const url = form.url.value.trim(); + const label = form.label.value.trim(); + try { + await api.post(`/api/songs/${songId}/tutorials`, { instrumentId, url, label }); + form.reset(); + const song = songs.find((s) => s.id === songId); + if (song) song.tutorial_count += 1; + await loadTutorials(songId); + renderSongs(); + } catch (err) { + showError(err.message); + } +} + +async function onAddSong(e) { + e.preventDefault(); + const form = e.target; + const title = form.title.value.trim(); + const artist = form.artist.value.trim(); + const notes = form.notes.value.trim(); + try { + await api.post('/api/songs', { title, artist, notes }); + form.reset(); + await loadSongs(); + } catch (err) { + showError(err.message); + } +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + me = await initNav('repertoire'); + await loadInstruments(); + if (me.isAdmin) { + document.getElementById('admin-add-song').style.display = 'block'; + document.getElementById('add-song-form').addEventListener('submit', onAddSong); + } + await loadSongs(); +})(); diff --git a/public/js/setlist.js b/public/js/setlist.js new file mode 100644 index 0000000..23799c0 --- /dev/null +++ b/public/js/setlist.js @@ -0,0 +1,194 @@ +let me = null; +let allSongs = []; +let setlist = null; +let editRows = []; + +function formatDate(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); +} + +function songOptions(selectedId) { + return allSongs + .map((s) => ``) + .join(''); +} + +function readOnlyView() { + const main = setlist.songs.filter((s) => !s.is_encore); + const encore = setlist.songs.filter((s) => s.is_encore); + return ` +
+
${escapeHtml(setlist.name || 'Concert')}
+
${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}
+
+
+

Setlist

+ ${main.length + ? `
    ${main.map((s) => `
  1. ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  2. `).join('')}
` + : '

Aucun morceau pour le moment.

'} +
+
+

Rappel

+ ${encore.length + ? `
    ${encore.map((s) => `
  1. ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  2. `).join('')}
` + : '

Aucun morceau de rappel prévu.

'} +
+ ${me.isAdmin ? '' : ''} + `; +} + +function editRowTemplate(row, index) { + return ` +
+
+ + + + + +
+
+ `; +} + +function editView() { + return ` +
+

Détails du concert

+
+ + + + +
+
+ ${setlist ? ` +
+ ${editRows.map(editRowTemplate).join('')} +
+
+ + + +
` : ''} + `; +} + +function renderReadOnly() { + const container = document.getElementById('content'); + if (!setlist) { + container.innerHTML = me.isAdmin + ? `

Aucun concert à venir.

${editView()}` + : '

Aucun concert à venir pour le moment.

'; + if (me.isAdmin) attachMetaFormHandler(); + return; + } + container.innerHTML = readOnlyView(); + const editBtn = document.getElementById('edit-btn'); + if (editBtn) editBtn.addEventListener('click', enterEditMode); +} + +function enterEditMode() { + editRows = setlist.songs.map((s) => ({ + setlistSongId: s.id, + songId: s.song_id, + position: s.position, + note: s.note, + isEncore: s.is_encore, + })); + renderEdit(); +} + +function renderEdit() { + const container = document.getElementById('content'); + container.innerHTML = editView(); + attachMetaFormHandler(); + attachEditHandlers(); +} + +function attachMetaFormHandler() { + const form = document.getElementById('meta-form'); + if (!form) return; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = form.name.value.trim(); + const venue = form.venue.value.trim(); + const concertDate = form.concertDate.value; + try { + if (setlist) { + setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate }); + setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs; + } else { + setlist = await api.post('/api/setlists', { name, venue, concertDate }); + setlist.songs = []; + } + enterEditMode(); + } catch (err) { + showError(err.message); + } + }); +} + +function attachEditHandlers() { + document.querySelectorAll('.remove-row').forEach((btn) => { + btn.addEventListener('click', () => { + editRows.splice(parseInt(btn.dataset.index, 10), 1); + renderEdit(); + }); + }); + document.querySelectorAll('[data-row-index]').forEach((rowEl) => { + const index = parseInt(rowEl.dataset.rowIndex, 10); + rowEl.querySelectorAll('[data-field]').forEach((input) => { + input.addEventListener('change', () => { + const field = input.dataset.field; + if (field === 'isEncore') editRows[index][field] = input.checked; + else if (field === 'position') editRows[index][field] = parseInt(input.value, 10); + else if (field === 'songId') editRows[index][field] = parseInt(input.value, 10); + else editRows[index][field] = input.value; + }); + }); + }); + const addBtn = document.getElementById('add-row-btn'); + if (addBtn) { + addBtn.addEventListener('click', () => { + const nextPos = editRows.filter((r) => !r.isEncore).length + 1; + editRows.push({ songId: allSongs[0]?.id, position: nextPos, note: '', isEncore: false }); + renderEdit(); + }); + } + const saveBtn = document.getElementById('save-songs-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + try { + const payload = editRows.map((r) => ({ + songId: r.songId, + position: r.position, + note: r.note, + isEncore: !!r.isEncore, + })); + setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload }); + renderReadOnly(); + } catch (err) { + showError(err.message); + } + }); + } + const cancelBtn = document.getElementById('cancel-edit-btn'); + if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly); +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + me = await initNav('setlist'); + if (me.isAdmin) { + allSongs = await api.get('/api/songs'); + } + setlist = await api.get('/api/setlists/next'); + renderReadOnly(); +})(); diff --git a/public/js/suggestions.js b/public/js/suggestions.js new file mode 100644 index 0000000..68481da --- /dev/null +++ b/public/js/suggestions.js @@ -0,0 +1,190 @@ +let me = null; +let suggestions = []; +const expanded = new Set(); + +async function loadSuggestions() { + suggestions = await api.get('/api/suggestions'); + renderSuggestions(); +} + +function statusLabel(status) { + return { pending: 'En attente', approved: 'Approuvé', rejected: 'Rejeté' }[status] || status; +} + +function suggestionTemplate(s) { + const isOpen = expanded.has(s.id); + const embed = youtubeEmbedUrl(s.youtube_url); + return ` +
+
+
+
${escapeHtml(s.title)}${s.artist ? ` — ${escapeHtml(s.artist)}` : ''}
+
Proposé par ${escapeHtml(s.suggested_by_name)} · ${statusLabel(s.status)}
+
+ +
+
+ ✔ ${s.approve_count} + ✘ ${s.reject_count} +
+
+

Chargement…

+
+
+ `; +} + +function detailTemplate(s) { + const embed = youtubeEmbedUrl(s.youtube_url); + const myVote = s.votes.find((v) => v.user_id === me.id); + return ` + ${embed ? `
` : `

${escapeHtml(s.youtube_url)}

`} + +
+ + + +
+ +
+ ${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '

Aucun vote pour le moment.

'} +
+ + ${me.isAdmin ? ` +
+ + + +
` : ''} + `; +} + +function voteItemTemplate(v) { + const icon = v.vote === 'approve' ? '✔' : '✘'; + return ` +
+ ${escapeHtml(v.voter_name)} ${icon} + ${v.comment ? `— ${escapeHtml(v.comment)}` : ''} +
+ `; +} + +function renderSuggestions() { + const container = document.getElementById('suggestions-list'); + renderList(container, suggestions, suggestionTemplate, 'Aucune suggestion pour le moment.'); + document.querySelectorAll('.toggle-detail').forEach((btn) => { + btn.addEventListener('click', () => onToggleDetail(parseInt(btn.dataset.id, 10))); + }); +} + +async function onToggleDetail(id) { + if (expanded.has(id)) { + expanded.delete(id); + } else { + expanded.add(id); + } + renderSuggestions(); + if (expanded.has(id)) { + await loadDetail(id); + } +} + +async function loadDetail(id) { + try { + const detail = await api.get(`/api/suggestions/${id}`); + const container = document.querySelector(`[data-detail-for="${id}"]`); + if (!container) return; + container.innerHTML = detailTemplate(detail); + container.querySelector('.vote-form').addEventListener('submit', (e) => onVote(e, id)); + const promoteBtn = container.querySelector('.promote-btn'); + if (promoteBtn) promoteBtn.addEventListener('click', () => onPromote(id)); + const rejectBtn = container.querySelector('.reject-btn'); + if (rejectBtn) rejectBtn.addEventListener('click', () => onReject(id)); + const deleteBtn = container.querySelector('.delete-btn'); + if (deleteBtn) deleteBtn.addEventListener('click', () => onDelete(id)); + } catch (err) { + showError(err.message); + } +} + +async function onVote(e, id) { + e.preventDefault(); + const form = e.target; + const vote = form.vote.value; + const comment = form.comment.value.trim(); + try { + await api.post(`/api/suggestions/${id}/vote`, { vote, comment }); + await loadSuggestions(); + expanded.add(id); + renderSuggestions(); + await loadDetail(id); + } catch (err) { + showError(err.message); + } +} + +async function onPromote(id) { + try { + await api.post(`/api/suggestions/${id}/promote`, {}); + await loadSuggestions(); + expanded.add(id); + renderSuggestions(); + await loadDetail(id); + } catch (err) { + showError(err.message); + } +} + +async function onReject(id) { + try { + await api.patch(`/api/suggestions/${id}`, { status: 'rejected' }); + await loadSuggestions(); + expanded.add(id); + renderSuggestions(); + await loadDetail(id); + } catch (err) { + showError(err.message); + } +} + +async function onDelete(id) { + try { + await api.del(`/api/suggestions/${id}`); + expanded.delete(id); + await loadSuggestions(); + } catch (err) { + showError(err.message); + } +} + +async function onAddSuggestion(e) { + e.preventDefault(); + const form = e.target; + const title = form.title.value.trim(); + const artist = form.artist.value.trim(); + const youtubeUrl = form.youtubeUrl.value.trim(); + try { + await api.post('/api/suggestions', { title, artist, youtubeUrl }); + form.reset(); + await loadSuggestions(); + } catch (err) { + showError(err.message); + } +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + me = await initNav('suggestions'); + document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion); + await loadSuggestions(); +})(); diff --git a/public/setlist.html b/public/setlist.html new file mode 100644 index 0000000..839781b --- /dev/null +++ b/public/setlist.html @@ -0,0 +1,24 @@ + + + + + +Prochain concert — Octane + + + +
+ +
+
+

Prochain concert

+
+
Chargement…
+
+ + + + + + + diff --git a/public/suggestions.html b/public/suggestions.html new file mode 100644 index 0000000..a4e09fb --- /dev/null +++ b/public/suggestions.html @@ -0,0 +1,34 @@ + + + + + +Suggestions — Octane + + + +
+ +
+
+

Suggestions de morceaux

+
+ +

Proposer un morceau

+
+ + + + +
+ +

Toutes les suggestions

+
+
+ + + + + + + diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..78e7f75 --- /dev/null +++ b/src/app.js @@ -0,0 +1,37 @@ +const path = require('path'); +const express = require('express'); +const sessionMiddleware = require('./auth/session'); +const authRoutes = require('./auth/routes'); +const { attachUser, requireAuth } = require('./auth/middleware'); +const apiRouter = require('./routes'); + +function createApp() { + const app = express(); + + app.get('/health', (req, res) => res.status(200).json({ status: 'ok' })); + + app.use(express.json()); + app.use(sessionMiddleware); + app.use('/auth', authRoutes); + app.use(attachUser); + + app.use('/api', requireAuth, apiRouter); + app.use(requireAuth, express.static(path.join(__dirname, '../public'))); + + app.use((req, res) => { + res.status(404).json({ error: 'not_found' }); + }); + + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + console.error(err); + if (req.path.startsWith('/api/')) { + return res.status(500).json({ error: 'internal_error' }); + } + res.status(500).send('Une erreur est survenue.'); + }); + + return app; +} + +module.exports = createApp; diff --git a/src/auth/middleware.js b/src/auth/middleware.js new file mode 100644 index 0000000..622d02b --- /dev/null +++ b/src/auth/middleware.js @@ -0,0 +1,31 @@ +const usersRepo = require('../repositories/usersRepo'); + +async function attachUser(req, res, next) { + try { + if (req.session.userId) { + req.user = await usersRepo.findById(req.session.userId); + } + next(); + } catch (err) { + next(err); + } +} + +function requireAuth(req, res, next) { + if (!req.user) { + if (req.path.startsWith('/api/')) { + return res.status(401).json({ error: 'unauthenticated' }); + } + return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`); + } + next(); +} + +function requireAdmin(req, res, next) { + if (!req.user || !req.user.is_admin) { + return res.status(403).json({ error: 'forbidden' }); + } + next(); +} + +module.exports = { attachUser, requireAuth, requireAdmin }; diff --git a/src/auth/oidc.js b/src/auth/oidc.js new file mode 100644 index 0000000..fd5fd8d --- /dev/null +++ b/src/auth/oidc.js @@ -0,0 +1,20 @@ +const client = require('openid-client'); +const config = require('../config'); + +let oidcConfig = null; + +async function initOidc() { + oidcConfig = await client.discovery( + new URL(config.authentikIssuerUrl), + config.oidcClientId, + config.oidcClientSecret + ); + return oidcConfig; +} + +function getOidcConfig() { + if (!oidcConfig) throw new Error('OIDC not initialized yet'); + return oidcConfig; +} + +module.exports = { initOidc, getOidcConfig, client }; diff --git a/src/auth/routes.js b/src/auth/routes.js new file mode 100644 index 0000000..bbf2dad --- /dev/null +++ b/src/auth/routes.js @@ -0,0 +1,88 @@ +const express = require('express'); +const { client, getOidcConfig } = require('./oidc'); +const usersRepo = require('../repositories/usersRepo'); +const config = require('../config'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/login', + asyncHandler(async (req, res) => { + const oidcConfig = getOidcConfig(); + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + const nonce = client.randomNonce(); + + req.session.oidc = { codeVerifier, state, nonce }; + if (req.query.returnTo) { + req.session.returnTo = req.query.returnTo; + } + + const authUrl = client.buildAuthorizationUrl(oidcConfig, { + scope: 'openid profile email groups', + redirect_uri: config.oidcRedirectUri, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + res.redirect(authUrl.href); + }) +); + +router.get( + '/callback', + asyncHandler(async (req, res) => { + const oidcConfig = getOidcConfig(); + const pending = req.session.oidc; + if (!pending) { + return res.status(400).send('Session de connexion expirée, réessayez.'); + } + + const currentUrl = new URL( + req.originalUrl, + `${req.protocol}://${req.get('host')}` + ); + + const tokens = await client.authorizationCodeGrant(oidcConfig, currentUrl, { + pkceCodeVerifier: pending.codeVerifier, + expectedState: pending.state, + expectedNonce: pending.nonce, + }); + + const claims = tokens.claims(); + const groups = Array.isArray(claims.groups) ? claims.groups : []; + const isAdmin = groups.includes(config.adminGroupName); + + const user = await usersRepo.upsertFromClaims({ + sub: claims.sub, + name: claims.name || claims.preferred_username || claims.email || claims.sub, + email: claims.email || null, + isAdmin, + }); + + const returnTo = req.session.returnTo || '/'; + delete req.session.oidc; + delete req.session.returnTo; + + req.session.regenerate((err) => { + if (err) throw err; + req.session.userId = user.id; + req.session.save((saveErr) => { + if (saveErr) throw saveErr; + res.redirect(returnTo); + }); + }); + }) +); + +router.get('/logout', (req, res) => { + req.session.destroy(() => { + res.redirect('/'); + }); +}); + +module.exports = router; diff --git a/src/auth/session.js b/src/auth/session.js new file mode 100644 index 0000000..5684ee7 --- /dev/null +++ b/src/auth/session.js @@ -0,0 +1,22 @@ +const session = require('express-session'); +const pgSessionFactory = require('connect-pg-simple'); +const pool = require('../db/pool'); +const config = require('../config'); + +const PgSession = pgSessionFactory(session); + +module.exports = session({ + store: new PgSession({ + pool, + createTableIfMissing: true, + }), + secret: config.sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: config.nodeEnv === 'production', + maxAge: 1000 * 60 * 60 * 24 * 30, + }, +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..65517c9 --- /dev/null +++ b/src/config.js @@ -0,0 +1,21 @@ +require('dotenv').config(); + +function required(name) { + const value = process.env[name]; + if (!value && process.env.NODE_ENV !== 'test') { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +module.exports = { + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT, 10) || 3000, + databaseUrl: required('DATABASE_URL'), + sessionSecret: required('SESSION_SECRET'), + authentikIssuerUrl: required('AUTHENTIK_ISSUER_URL'), + oidcClientId: required('OIDC_CLIENT_ID'), + oidcClientSecret: required('OIDC_CLIENT_SECRET'), + oidcRedirectUri: required('OIDC_REDIRECT_URI'), + adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins', +}; diff --git a/src/db/migrate.js b/src/db/migrate.js new file mode 100644 index 0000000..61906e0 --- /dev/null +++ b/src/db/migrate.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const path = require('path'); +const pool = require('./pool'); + +const MIGRATIONS_DIR = path.join(__dirname, 'migrations'); + +async function runMigrations() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + const { rows } = await client.query('SELECT filename FROM schema_migrations'); + const applied = new Set(rows.map((r) => r.filename)); + + const files = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + console.log(`Applying migration: ${file}`); + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + } finally { + client.release(); + } +} + +if (require.main === module) { + runMigrations() + .then(() => { + console.log('Migrations complete.'); + return pool.end(); + }) + .catch((err) => { + console.error('Migration failed:', err); + process.exit(1); + }); +} + +module.exports = { runMigrations }; diff --git a/src/db/migrations/001_init.sql b/src/db/migrations/001_init.sql new file mode 100644 index 0000000..1041bf0 --- /dev/null +++ b/src/db/migrations/001_init.sql @@ -0,0 +1,92 @@ +-- Users, keyed by Authentik OIDC "sub" claim +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + authentik_sub TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + email TEXT, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Instruments (fixed-ish list) +CREATE TABLE instruments ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Repertoire +CREATE TABLE songs ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + artist TEXT NOT NULL, + notes TEXT, + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Per-song, per-instrument tutorial/resource links +CREATE TABLE song_tutorials ( + id SERIAL PRIMARY KEY, + song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + instrument_id INTEGER NOT NULL REFERENCES instruments(id) ON DELETE RESTRICT, + url TEXT NOT NULL, + label TEXT, + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_song_tutorials_song_id ON song_tutorials(song_id); + +-- Song suggestions +CREATE TABLE suggestions ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + artist TEXT, + youtube_url TEXT NOT NULL, + suggested_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected')), + promoted_song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Votes/comments on suggestions, one per user per suggestion +CREATE TABLE suggestion_votes ( + id SERIAL PRIMARY KEY, + suggestion_id INTEGER NOT NULL REFERENCES suggestions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote TEXT NOT NULL CHECK (vote IN ('approve', 'reject')), + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (suggestion_id, user_id) +); + +-- Concerts / setlists +CREATE TABLE setlists ( + id SERIAL PRIMARY KEY, + name TEXT, + venue TEXT, + concert_date DATE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Songs within a setlist, ordered, with per-song note and encore flag +CREATE TABLE setlist_songs ( + id SERIAL PRIMARY KEY, + setlist_id INTEGER NOT NULL REFERENCES setlists(id) ON DELETE CASCADE, + song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + note TEXT, + is_encore BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (setlist_id, song_id) +); +CREATE INDEX idx_setlist_songs_setlist_id ON setlist_songs(setlist_id); +CREATE UNIQUE INDEX uq_setlist_position_main + ON setlist_songs(setlist_id, position) WHERE NOT is_encore; +CREATE UNIQUE INDEX uq_setlist_position_encore + ON setlist_songs(setlist_id, position) WHERE is_encore; diff --git a/src/db/migrations/002_seed_instruments.sql b/src/db/migrations/002_seed_instruments.sql new file mode 100644 index 0000000..332a59f --- /dev/null +++ b/src/db/migrations/002_seed_instruments.sql @@ -0,0 +1,4 @@ +INSERT INTO instruments (name) VALUES + ('chant'), ('guitare'), ('basse'), ('batterie'), ('clavier'), + ('percussions'), ('cuivres'), ('autre') +ON CONFLICT (name) DO NOTHING; diff --git a/src/db/pool.js b/src/db/pool.js new file mode 100644 index 0000000..3dea200 --- /dev/null +++ b/src/db/pool.js @@ -0,0 +1,6 @@ +const { Pool } = require('pg'); +const config = require('../config'); + +const pool = new Pool({ connectionString: config.databaseUrl }); + +module.exports = pool; diff --git a/src/lib/asyncHandler.js b/src/lib/asyncHandler.js new file mode 100644 index 0000000..03bca7f --- /dev/null +++ b/src/lib/asyncHandler.js @@ -0,0 +1,3 @@ +module.exports = function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +}; diff --git a/src/lib/youtube.js b/src/lib/youtube.js new file mode 100644 index 0000000..6adb280 --- /dev/null +++ b/src/lib/youtube.js @@ -0,0 +1,28 @@ +const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'youtu.be', 'm.youtube.com']); + +function extractVideoId(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + return null; + } + if (!YOUTUBE_HOSTS.has(parsed.hostname)) return null; + + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1) || null; + } + if (parsed.pathname === '/watch') { + return parsed.searchParams.get('v'); + } + if (parsed.pathname.startsWith('/embed/')) { + return parsed.pathname.split('/embed/')[1] || null; + } + return null; +} + +function isValidYoutubeUrl(url) { + return extractVideoId(url) !== null; +} + +module.exports = { extractVideoId, isValidYoutubeUrl }; diff --git a/src/repositories/instrumentsRepo.js b/src/repositories/instrumentsRepo.js new file mode 100644 index 0000000..ae9fbdc --- /dev/null +++ b/src/repositories/instrumentsRepo.js @@ -0,0 +1,8 @@ +const pool = require('../db/pool'); + +async function findAll() { + const { rows } = await pool.query('SELECT id, name FROM instruments ORDER BY name'); + return rows; +} + +module.exports = { findAll }; diff --git a/src/repositories/setlistsRepo.js b/src/repositories/setlistsRepo.js new file mode 100644 index 0000000..23ec04f --- /dev/null +++ b/src/repositories/setlistsRepo.js @@ -0,0 +1,118 @@ +const pool = require('../db/pool'); + +async function findNext() { + const { rows } = await pool.query( + `SELECT id, name, venue, concert_date, created_by, created_at, updated_at + FROM setlists + WHERE concert_date >= CURRENT_DATE + ORDER BY concert_date ASC + LIMIT 1` + ); + return rows[0] || null; +} + +async function findHistory() { + const { rows } = await pool.query( + `SELECT id, name, venue, concert_date, created_by, created_at, updated_at + FROM setlists + WHERE concert_date < CURRENT_DATE + ORDER BY concert_date DESC` + ); + return rows; +} + +async function findById(id) { + const { rows } = await pool.query( + `SELECT id, name, venue, concert_date, created_by, created_at, updated_at + FROM setlists WHERE id = $1`, + [id] + ); + return rows[0] || null; +} + +async function findSongs(setlistId) { + const { rows } = await pool.query( + `SELECT ss.id, ss.setlist_id, ss.song_id, s.title, s.artist, ss.position, ss.note, ss.is_encore + FROM setlist_songs ss + JOIN songs s ON s.id = ss.song_id + WHERE ss.setlist_id = $1 + ORDER BY ss.is_encore, ss.position`, + [setlistId] + ); + return rows; +} + +async function create({ name, venue, concertDate, createdBy }) { + const { rows } = await pool.query( + `INSERT INTO setlists (name, venue, concert_date, created_by) + VALUES ($1, $2, $3, $4) + RETURNING id, name, venue, concert_date, created_by, created_at, updated_at`, + [name || null, venue || null, concertDate, createdBy] + ); + return rows[0]; +} + +async function update(id, { name, venue, concertDate }) { + const { rows } = await pool.query( + `UPDATE setlists SET name = $2, venue = $3, concert_date = $4, updated_at = now() + WHERE id = $1 + RETURNING id, name, venue, concert_date, created_by, created_at, updated_at`, + [id, name || null, venue || null, concertDate] + ); + return rows[0] || null; +} + +async function remove(id) { + await pool.query('DELETE FROM setlists WHERE id = $1', [id]); +} + +async function replaceSongs(setlistId, songEntries) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM setlist_songs WHERE setlist_id = $1', [setlistId]); + for (const entry of songEntries) { + await client.query( + `INSERT INTO setlist_songs (setlist_id, song_id, position, note, is_encore) + VALUES ($1, $2, $3, $4, $5)`, + [setlistId, entry.songId, entry.position, entry.note || null, !!entry.isEncore] + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function addSong(setlistId, { songId, position, note, isEncore }) { + const { rows } = await pool.query( + `INSERT INTO setlist_songs (setlist_id, song_id, position, note, is_encore) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, setlist_id, song_id, position, note, is_encore`, + [setlistId, songId, position, note || null, !!isEncore] + ); + return rows[0]; +} + +async function removeSong(setlistId, setlistSongId) { + await pool.query('DELETE FROM setlist_songs WHERE id = $1 AND setlist_id = $2', [ + setlistSongId, + setlistId, + ]); +} + +module.exports = { + findNext, + findHistory, + findById, + findSongs, + create, + update, + remove, + replaceSongs, + addSong, + removeSong, +}; diff --git a/src/repositories/songsRepo.js b/src/repositories/songsRepo.js new file mode 100644 index 0000000..5664b0c --- /dev/null +++ b/src/repositories/songsRepo.js @@ -0,0 +1,86 @@ +const pool = require('../db/pool'); + +async function findAll() { + const { rows } = await pool.query(` + SELECT s.id, s.title, s.artist, s.notes, s.created_at, + COUNT(st.id)::int AS tutorial_count + FROM songs s + LEFT JOIN song_tutorials st ON st.song_id = s.id + GROUP BY s.id + ORDER BY s.title + `); + return rows; +} + +async function findById(id) { + const { rows } = await pool.query( + 'SELECT id, title, artist, notes, added_by, created_at, updated_at FROM songs WHERE id = $1', + [id] + ); + return rows[0] || null; +} + +async function create({ title, artist, notes, addedBy }) { + const { rows } = await pool.query( + `INSERT INTO songs (title, artist, notes, added_by) + VALUES ($1, $2, $3, $4) + RETURNING id, title, artist, notes, added_by, created_at, updated_at`, + [title, artist, notes || null, addedBy] + ); + return rows[0]; +} + +async function update(id, { title, artist, notes }) { + const { rows } = await pool.query( + `UPDATE songs SET title = $2, artist = $3, notes = $4, updated_at = now() + WHERE id = $1 + RETURNING id, title, artist, notes, added_by, created_at, updated_at`, + [id, title, artist, notes || null] + ); + return rows[0] || null; +} + +async function remove(id) { + await pool.query('DELETE FROM songs WHERE id = $1', [id]); +} + +async function findTutorials(songId) { + const { rows } = await pool.query( + `SELECT st.id, st.song_id, st.instrument_id, i.name AS instrument_name, + st.url, st.label, st.added_by, st.created_at + FROM song_tutorials st + JOIN instruments i ON i.id = st.instrument_id + WHERE st.song_id = $1 + ORDER BY i.name`, + [songId] + ); + return rows; +} + +async function addTutorial(songId, { instrumentId, url, label, addedBy }) { + const { rows } = await pool.query( + `INSERT INTO song_tutorials (song_id, instrument_id, url, label, added_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, song_id, instrument_id, url, label, added_by, created_at`, + [songId, instrumentId, url, label || null, addedBy] + ); + return rows[0]; +} + +async function removeTutorial(songId, tutorialId) { + await pool.query('DELETE FROM song_tutorials WHERE id = $1 AND song_id = $2', [ + tutorialId, + songId, + ]); +} + +module.exports = { + findAll, + findById, + create, + update, + remove, + findTutorials, + addTutorial, + removeTutorial, +}; diff --git a/src/repositories/suggestionsRepo.js b/src/repositories/suggestionsRepo.js new file mode 100644 index 0000000..f4f611e --- /dev/null +++ b/src/repositories/suggestionsRepo.js @@ -0,0 +1,127 @@ +const pool = require('../db/pool'); + +async function findAll() { + const { rows } = await pool.query(` + SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.status, sg.promoted_song_id, + sg.created_at, sg.suggested_by, u.name AS suggested_by_name, + COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count, + COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count + FROM suggestions sg + JOIN users u ON u.id = sg.suggested_by + LEFT JOIN suggestion_votes v ON v.suggestion_id = sg.id + GROUP BY sg.id, u.name + ORDER BY sg.created_at DESC + `); + return rows; +} + +async function findById(id) { + const { rows } = await pool.query( + `SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.status, sg.promoted_song_id, + sg.created_at, sg.suggested_by, u.name AS suggested_by_name + FROM suggestions sg + JOIN users u ON u.id = sg.suggested_by + WHERE sg.id = $1`, + [id] + ); + return rows[0] || null; +} + +async function findVotes(suggestionId) { + const { rows } = await pool.query( + `SELECT v.id, v.user_id, u.name AS voter_name, v.vote, v.comment, v.created_at, v.updated_at + FROM suggestion_votes v + JOIN users u ON u.id = v.user_id + WHERE v.suggestion_id = $1 + ORDER BY v.created_at`, + [suggestionId] + ); + return rows; +} + +async function create({ title, artist, youtubeUrl, suggestedBy }) { + const { rows } = await pool.query( + `INSERT INTO suggestions (title, artist, youtube_url, suggested_by) + VALUES ($1, $2, $3, $4) + RETURNING id, title, artist, youtube_url, status, promoted_song_id, created_at, suggested_by`, + [title, artist || null, youtubeUrl, suggestedBy] + ); + return rows[0]; +} + +async function updateStatus(id, status) { + const { rows } = await pool.query( + `UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1 + RETURNING id, title, artist, youtube_url, status, promoted_song_id, created_at, suggested_by`, + [id, status] + ); + return rows[0] || null; +} + +async function remove(id) { + await pool.query('DELETE FROM suggestions WHERE id = $1', [id]); +} + +async function upsertVote(suggestionId, userId, { vote, comment }) { + const { rows } = await pool.query( + `INSERT INTO suggestion_votes (suggestion_id, user_id, vote, comment) + VALUES ($1, $2, $3, $4) + ON CONFLICT (suggestion_id, user_id) + DO UPDATE SET vote = $3, comment = $4, updated_at = now() + RETURNING id, suggestion_id, user_id, vote, comment, created_at, updated_at`, + [suggestionId, userId, vote, comment || null] + ); + return rows[0]; +} + +async function removeVote(suggestionId, userId) { + await pool.query('DELETE FROM suggestion_votes WHERE suggestion_id = $1 AND user_id = $2', [ + suggestionId, + userId, + ]); +} + +async function promoteToSong(suggestionId, addedBy) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: sugRows } = await client.query('SELECT * FROM suggestions WHERE id = $1 FOR UPDATE', [ + suggestionId, + ]); + const suggestion = sugRows[0]; + if (!suggestion) { + await client.query('ROLLBACK'); + return null; + } + const { rows: songRows } = await client.query( + `INSERT INTO songs (title, artist, added_by) + VALUES ($1, $2, $3) + RETURNING id, title, artist, notes, added_by, created_at, updated_at`, + [suggestion.title, suggestion.artist || suggestion.title, addedBy] + ); + const song = songRows[0]; + await client.query( + `UPDATE suggestions SET status = 'approved', promoted_song_id = $2, updated_at = now() WHERE id = $1`, + [suggestionId, song.id] + ); + await client.query('COMMIT'); + return song; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +module.exports = { + findAll, + findById, + findVotes, + create, + updateStatus, + remove, + upsertVote, + removeVote, + promoteToSong, +}; diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js new file mode 100644 index 0000000..2756e9f --- /dev/null +++ b/src/repositories/usersRepo.js @@ -0,0 +1,23 @@ +const pool = require('../db/pool'); + +async function findById(id) { + const { rows } = await pool.query( + 'SELECT id, authentik_sub, name, email, is_admin, created_at FROM users WHERE id = $1', + [id] + ); + return rows[0] || null; +} + +async function upsertFromClaims({ sub, name, email, isAdmin }) { + const { rows } = await pool.query( + `INSERT INTO users (authentik_sub, name, email, is_admin) + VALUES ($1, $2, $3, $4) + ON CONFLICT (authentik_sub) + DO UPDATE SET name = $2, email = $3, is_admin = $4, updated_at = now() + RETURNING id, authentik_sub, name, email, is_admin, created_at`, + [sub, name, email, isAdmin] + ); + return rows[0]; +} + +module.exports = { findById, upsertFromClaims }; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..bf26db9 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,16 @@ +const express = require('express'); +const usersRoutes = require('./users'); +const instrumentsRoutes = require('./instruments'); +const songsRoutes = require('./songs'); +const suggestionsRoutes = require('./suggestions'); +const setlistsRoutes = require('./setlists'); + +const router = express.Router(); + +router.use('/users', usersRoutes); +router.use('/instruments', instrumentsRoutes); +router.use('/songs', songsRoutes); +router.use('/suggestions', suggestionsRoutes); +router.use('/setlists', setlistsRoutes); + +module.exports = router; diff --git a/src/routes/instruments.js b/src/routes/instruments.js new file mode 100644 index 0000000..695d99b --- /dev/null +++ b/src/routes/instruments.js @@ -0,0 +1,15 @@ +const express = require('express'); +const instrumentsRepo = require('../repositories/instrumentsRepo'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const instruments = await instrumentsRepo.findAll(); + res.json(instruments); + }) +); + +module.exports = router; diff --git a/src/routes/setlists.js b/src/routes/setlists.js new file mode 100644 index 0000000..c1eab25 --- /dev/null +++ b/src/routes/setlists.js @@ -0,0 +1,123 @@ +const express = require('express'); +const setlistsRepo = require('../repositories/setlistsRepo'); +const { requireAdmin } = require('../auth/middleware'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +async function withSongs(setlist) { + if (!setlist) return null; + const songs = await setlistsRepo.findSongs(setlist.id); + return { ...setlist, songs }; +} + +router.get( + '/next', + asyncHandler(async (req, res) => { + const setlist = await setlistsRepo.findNext(); + res.json(await withSongs(setlist)); + }) +); + +router.get( + '/history', + asyncHandler(async (req, res) => { + res.json(await setlistsRepo.findHistory()); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const setlist = await setlistsRepo.findById(req.params.id); + if (!setlist) return res.status(404).json({ error: 'not_found' }); + res.json(await withSongs(setlist)); + }) +); + +router.post( + '/', + requireAdmin, + asyncHandler(async (req, res) => { + const { name, venue, concertDate } = req.body || {}; + if (!concertDate) return res.status(400).json({ error: 'concert_date_required' }); + const setlist = await setlistsRepo.create({ name, venue, concertDate, createdBy: req.user.id }); + res.status(201).json(setlist); + }) +); + +router.patch( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + const { name, venue, concertDate } = req.body || {}; + if (!concertDate) return res.status(400).json({ error: 'concert_date_required' }); + const setlist = await setlistsRepo.update(req.params.id, { name, venue, concertDate }); + if (!setlist) return res.status(404).json({ error: 'not_found' }); + res.json(setlist); + }) +); + +router.delete( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + await setlistsRepo.remove(req.params.id); + res.status(204).end(); + }) +); + +router.put( + '/:id/songs', + requireAdmin, + asyncHandler(async (req, res) => { + const { songs } = req.body || {}; + if (!Array.isArray(songs)) return res.status(400).json({ error: 'songs_array_required' }); + for (const s of songs) { + if (!s.songId || typeof s.position !== 'number') { + return res.status(400).json({ error: 'invalid_song_entry' }); + } + } + try { + await setlistsRepo.replaceSongs(req.params.id, songs); + } catch (err) { + if (err.code === '23505') { + return res.status(409).json({ error: 'duplicate_position', message: 'Deux morceaux ont la même position.' }); + } + throw err; + } + const setlist = await setlistsRepo.findById(req.params.id); + res.json(await withSongs(setlist)); + }) +); + +router.post( + '/:id/songs', + requireAdmin, + asyncHandler(async (req, res) => { + const { songId, position, note, isEncore } = req.body || {}; + if (!songId || typeof position !== 'number') { + return res.status(400).json({ error: 'invalid_song_entry' }); + } + try { + const entry = await setlistsRepo.addSong(req.params.id, { songId, position, note, isEncore }); + res.status(201).json(entry); + } catch (err) { + if (err.code === '23505') { + return res.status(409).json({ error: 'duplicate_position', message: 'Ce morceau ou cette position est déjà utilisé.' }); + } + throw err; + } + }) +); + +router.delete( + '/:id/songs/:setlistSongId', + requireAdmin, + asyncHandler(async (req, res) => { + await setlistsRepo.removeSong(req.params.id, req.params.setlistSongId); + res.status(204).end(); + }) +); + +module.exports = router; diff --git a/src/routes/songs.js b/src/routes/songs.js new file mode 100644 index 0000000..c63421a --- /dev/null +++ b/src/routes/songs.js @@ -0,0 +1,99 @@ +const express = require('express'); +const songsRepo = require('../repositories/songsRepo'); +const { requireAdmin } = require('../auth/middleware'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + res.json(await songsRepo.findAll()); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const song = await songsRepo.findById(req.params.id); + if (!song) return res.status(404).json({ error: 'not_found' }); + const tutorials = await songsRepo.findTutorials(req.params.id); + res.json({ ...song, tutorials }); + }) +); + +router.post( + '/', + requireAdmin, + asyncHandler(async (req, res) => { + const { title, artist, notes } = req.body || {}; + if (!title || !title.trim() || !artist || !artist.trim()) { + return res.status(400).json({ error: 'title_and_artist_required' }); + } + const song = await songsRepo.create({ title: title.trim(), artist: artist.trim(), notes, addedBy: req.user.id }); + res.status(201).json(song); + }) +); + +router.patch( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + const { title, artist, notes } = req.body || {}; + if (!title || !title.trim() || !artist || !artist.trim()) { + return res.status(400).json({ error: 'title_and_artist_required' }); + } + const song = await songsRepo.update(req.params.id, { title: title.trim(), artist: artist.trim(), notes }); + if (!song) return res.status(404).json({ error: 'not_found' }); + res.json(song); + }) +); + +router.delete( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + try { + await songsRepo.remove(req.params.id); + res.status(204).end(); + } catch (err) { + if (err.code === '23503') { + return res + .status(409) + .json({ error: 'song_in_use', message: 'Ce morceau est utilisé dans une setlist et ne peut pas être supprimé.' }); + } + throw err; + } + }) +); + +router.post( + '/:id/tutorials', + requireAdmin, + asyncHandler(async (req, res) => { + const { instrumentId, url, label } = req.body || {}; + if (!instrumentId || !url || !url.trim()) { + return res.status(400).json({ error: 'instrument_and_url_required' }); + } + const song = await songsRepo.findById(req.params.id); + if (!song) return res.status(404).json({ error: 'not_found' }); + const tutorial = await songsRepo.addTutorial(req.params.id, { + instrumentId, + url: url.trim(), + label, + addedBy: req.user.id, + }); + res.status(201).json(tutorial); + }) +); + +router.delete( + '/:songId/tutorials/:id', + requireAdmin, + asyncHandler(async (req, res) => { + await songsRepo.removeTutorial(req.params.songId, req.params.id); + res.status(204).end(); + }) +); + +module.exports = router; diff --git a/src/routes/suggestions.js b/src/routes/suggestions.js new file mode 100644 index 0000000..18933f2 --- /dev/null +++ b/src/routes/suggestions.js @@ -0,0 +1,101 @@ +const express = require('express'); +const suggestionsRepo = require('../repositories/suggestionsRepo'); +const { requireAdmin } = require('../auth/middleware'); +const { isValidYoutubeUrl } = require('../lib/youtube'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + res.json(await suggestionsRepo.findAll()); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const suggestion = await suggestionsRepo.findById(req.params.id); + if (!suggestion) return res.status(404).json({ error: 'not_found' }); + const votes = await suggestionsRepo.findVotes(req.params.id); + res.json({ ...suggestion, votes }); + }) +); + +router.post( + '/', + asyncHandler(async (req, res) => { + const { title, artist, youtubeUrl } = req.body || {}; + if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) { + return res.status(400).json({ error: 'title_and_youtube_url_required' }); + } + if (!isValidYoutubeUrl(youtubeUrl.trim())) { + return res.status(400).json({ error: 'invalid_youtube_url' }); + } + const suggestion = await suggestionsRepo.create({ + title: title.trim(), + artist: artist ? artist.trim() : null, + youtubeUrl: youtubeUrl.trim(), + suggestedBy: req.user.id, + }); + res.status(201).json(suggestion); + }) +); + +router.patch( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + const { status } = req.body || {}; + if (!['pending', 'approved', 'rejected'].includes(status)) { + return res.status(400).json({ error: 'invalid_status' }); + } + const suggestion = await suggestionsRepo.updateStatus(req.params.id, status); + if (!suggestion) return res.status(404).json({ error: 'not_found' }); + res.json(suggestion); + }) +); + +router.delete( + '/:id', + requireAdmin, + asyncHandler(async (req, res) => { + await suggestionsRepo.remove(req.params.id); + res.status(204).end(); + }) +); + +router.post( + '/:id/vote', + asyncHandler(async (req, res) => { + const { vote, comment } = req.body || {}; + if (!['approve', 'reject'].includes(vote)) { + return res.status(400).json({ error: 'invalid_vote' }); + } + const suggestion = await suggestionsRepo.findById(req.params.id); + if (!suggestion) return res.status(404).json({ error: 'not_found' }); + const result = await suggestionsRepo.upsertVote(req.params.id, req.user.id, { vote, comment }); + res.status(200).json(result); + }) +); + +router.delete( + '/:id/vote', + asyncHandler(async (req, res) => { + await suggestionsRepo.removeVote(req.params.id, req.user.id); + res.status(204).end(); + }) +); + +router.post( + '/:id/promote', + requireAdmin, + asyncHandler(async (req, res) => { + const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id); + if (!song) return res.status(404).json({ error: 'not_found' }); + res.status(201).json(song); + }) +); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..37ed877 --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,14 @@ +const express = require('express'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/me', + asyncHandler(async (req, res) => { + const { id, name, email, is_admin } = req.user; + res.json({ id, name, email, isAdmin: is_admin }); + }) +); + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..3bd7005 --- /dev/null +++ b/src/server.js @@ -0,0 +1,19 @@ +const config = require('./config'); +const createApp = require('./app'); +const { initOidc } = require('./auth/oidc'); +const { runMigrations } = require('./db/migrate'); + +async function main() { + await runMigrations(); + await initOidc(); + + const app = createApp(); + app.listen(config.port, () => { + console.log(`Octane website listening on port ${config.port}`); + }); +} + +main().catch((err) => { + console.error('Failed to start server:', err); + process.exit(1); +});