diff --git a/.docker/create-dbs.sql b/.docker/create-dbs.sql new file mode 100644 index 00000000..21df7ea3 --- /dev/null +++ b/.docker/create-dbs.sql @@ -0,0 +1,2 @@ +CREATE DATABASE friends; +CREATE DATABASE miiverse; diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 9a1ab02d..81e5cda2 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -8,6 +8,18 @@ services: - "6379:6379" volumes: - redis_data:/data + postgres: + image: postgres:18 + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: "postgres" + POSTGRES_USER: "postgres" + POSTGRES_DB: "postgres" + volumes: + - "db_data:/var/lib/postgresql" + - ./create-dbs.sql:/docker-entrypoint-initdb.d/init.sql mongo: image: mongo:8.0 restart: unless-stopped @@ -125,20 +137,9 @@ services: PN_FRIENDS_ACCOUNT_GRPC_PORT: 8123 PN_FRIENDS_ACCOUNT_GRPC_API_KEY: "12345678123456781234567812345678" - postgres: - image: postgres - restart: unless-stopped - ports: - - "5432:5432" - environment: - POSTGRES_PASSWORD: "postgres" - POSTGRES_USER: "postgres" - POSTGRES_DB: "friends" - volumes: - - "pg_data:/var/lib/postgresql" volumes: - pg_data: mongo_data: mongo_config: redis_data: minio_data: + db_data: diff --git a/.github/workflows/bundle-analysis.yml b/.github/workflows/bundle-analysis.yml deleted file mode 100644 index 92f42883..00000000 --- a/.github/workflows/bundle-analysis.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Bundle analysis - -# This is intentionall split into two parts: -# - bundle-build (builds untrusted code securely and stores metafiles) -# - bundle-analysis (reads metafiles and comments, no untrusted code) -# This is to prevent untrusted code being ran with a token that has write access to PRs - -on: - workflow_run: - workflows: ["Compile bundle analysis"] - types: - - completed - -jobs: - analyze: - name: Analyze bundle - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - pull-requests: write - if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' - steps: - - name: Download artifact from build workflow - uses: actions/download-artifact@v7 - with: - name: bundle-metafiles - path: metafiles - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Analyze esbuild bundle size - uses: exoego/esbuild-bundle-analyzer@v1 - with: - metafiles: "metafiles/**/metafile-*.json" diff --git a/.github/workflows/bundle-build.yml b/.github/workflows/bundle-build.yml deleted file mode 100644 index 3fa66d58..00000000 --- a/.github/workflows/bundle-build.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Compile bundle analysis - -on: - pull_request: - -jobs: - compile: - name: Compile - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read # for checkout repository - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Fetch the commit SHA of the forked PR - ref: "${{ github.event.pull_request.merge_commit_sha }}" - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - - - name: Install dependencies - run: npm ci - - - name: Build miiverse-api - run: npm run build - working-directory: ./apps/miiverse-api - - - name: Build juxtaposition-ui with metafiles - run: npm run build:meta - working-directory: ./apps/juxtaposition-ui - - - name: Upload metafiles artifact - uses: actions/upload-artifact@v7 - with: - name: bundle-metafiles - path: apps/juxtaposition-ui/dist/webfiles/*/metafile-*.json diff --git a/.gitignore b/.gitignore index c9abc464..8d535146 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ apps/juxtaposition-ui/webfiles/ctr/css/sprites.css apps/juxtaposition-ui/webfiles/ctr/images/sprites.png apps/juxtaposition-ui/src/api/generated apps/miiverse-api/internal.openapi.json +apps/miiverse-api/src/prisma diff --git a/.vscode/settings.json b/.vscode/settings.json index f32902cf..3e0053f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ ], "eslint.format.enable": true, "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "js/ts.preferences.importModuleSpecifier": "non-relative", "[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, @@ -18,12 +19,13 @@ "[html]": { "editor.defaultFormatter": "vscode.html-language-features" }, - "javascript.preferences.importModuleSpecifier": "non-relative", - "typescript.preferences.importModuleSpecifier": "non-relative", "[typescriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "[scss]": { "editor.defaultFormatter": "vscode.css-language-features" }, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + } } \ No newline at end of file diff --git a/README.md b/README.md index a3117f8f..7f960de7 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,9 @@ This means we both want to bring all features originally found in Miiverse into - The web platforms on the 3DS and Wii U are old, thus we need to use old web methodologies like AJAX. - The XML API of the Miiverse platform cannot be modified or extended, it needs to stay exactly as the consoles expect it. -# Running locally for development - -Prerequisites: -- Clone the repository -- Have a functional running [account server](https://github.com/PretendoNetwork/account) and [friends server](https://github.com/PretendoNetwork/friends) -- Have NodeJS 20 or higher installed -- Optional: have docker installed (highly recommended) - -After the prerequisites you need to run the following inside `.docker`: -```sh -docker compose up -d -``` -If you are not using docker for development, please set up the services listed in `.docker/docker-compose.yml` manually. - -Next up, you need to run the two services in `/apps`: -```bash -cd apps/miiverse-api -npm i -npm run dev -``` - -And in another terminal: -```bash -cd apps/juxtaposition-ui -npm i -npm run dev -``` - -You have to also make an `.env` file to configure your environment. Inspire it from the schema in `src/config.ts` in each service. - -You can use `PN_JUXTAPOSITION_UI_USE_PRESETS=docker` and `PN_MIIVERSE_API_USE_PRESETS=docker` to automatically set up everything that's in the docker compose file. +# Development + +Follow [this guide](./docs/development.md) on how to get set up for local development. # Translation diff --git a/apps/juxtaposition-ui/eslint.config.mjs b/apps/juxtaposition-ui/eslint.config.mjs index 4911def0..53b87d1e 100644 --- a/apps/juxtaposition-ui/eslint.config.mjs +++ b/apps/juxtaposition-ui/eslint.config.mjs @@ -4,6 +4,11 @@ import { defineConfig } from 'eslint/config'; export default defineConfig([ ...eslintConfig, + { + rules: { + '@stylistic/jsx-one-expression-per-line': 'off' + } + }, { // Allow browser globals in webfiles languageOptions: { diff --git a/apps/juxtaposition-ui/package.json b/apps/juxtaposition-ui/package.json index 2be664af..420a848c 100644 --- a/apps/juxtaposition-ui/package.json +++ b/apps/juxtaposition-ui/package.json @@ -16,7 +16,6 @@ "start": "node --enable-source-maps dist/server.js" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1004.0", "@hey-api/openapi-ts": "^0.95.0", "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", @@ -31,17 +30,13 @@ "express-prom-bundle": "^7.0.2", "express-rate-limit": "^8.2.1", "express-session": "^1.19.0", - "hashmap": "^2.4.0", "i18next": "^25.8.18", "include-media": "github:eduardoboucas/include-media#78c6aeb", "luxon": "^3.7.2", "method-override": "^3.0.0", "moment": "^2.30.1", - "mongoose": "^8.23.0", "multer": "^2.1.1", "nice-grpc": "^2.1.14", - "node-snowflake": "0.0.1", - "pako": "^2.1.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -51,7 +46,6 @@ "react-dom": "^19.2.4", "react-i18next": "^16.5.8", "redis": "^5.11.0", - "tsx": "^4.21.0", "zod": "^4.3.6" }, "devDependencies": { @@ -65,7 +59,6 @@ "@repo/esbuild-plugin-spritesmith": "^0.0.0", "@types/cookie-parser": "^1.4.10", "@types/express-session": "^1.18.2", - "@types/hashmap": "^2.3.4", "@types/luxon": "^3.7.1", "@types/method-override": "^3.0.0", "@types/node": "^24.12.0", @@ -81,6 +74,7 @@ "nodemon": "^3.1.14", "npm-run-all": "^4.1.5", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/ca.json b/apps/juxtaposition-ui/src/assets/locales/ca.json index 4dc7bc07..3c09a66a 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ca.json +++ b/apps/juxtaposition-ui/src/assets/locales/ca.json @@ -14,7 +14,14 @@ "user_page": "Pàgina d'usuari", "go_back": "Torna", "yeahs": "Mola", - "search": "Cerca..." + "search": "Cerca...", + "my_feed": "Les meves Publicacions", + "global_feed": "Publicacions Globals", + "global_feed_short": "Global", + "people_feed": "Seguint", + "people_feed_short": "Seguits", + "friend_requests": "Sol·licituds d'Amistat", + "updates": "Actualitzacions" }, "notifications": { "none": "No hi ha notificacions." @@ -35,7 +42,10 @@ "tags": "Etiquetes", "followers": "Seguidors", "recent": "Publicacions recents", - "popular": "Publicacions populars" + "popular": "Publicacions populars", + "followers_count": "{{count}} seguidors", + "tags_not_applicable": "N/A", + "related": "Comunitats relacionades" }, "user_settings": { "show_game": "Mostra la experiència de joc al perfil", @@ -103,9 +113,17 @@ "forgot_password": "¿Has oblidat la teva contrasenya?", "no_account": "¿No tens cap compte?", "login_action": "Iniciar sessió", - "no_account_setup": "La creació de comptes només estarà disponible quan hagis vinculat una Wii U o 3DS." + "no_account_setup": "La creació de comptes només estarà disponible quan hagis vinculat una Wii U o 3DS.", + "title": "Inicia la sessió per juxtaposició", + "heading": "Registre per juxtaposició", + "sub_title": "Introduïu les dades del vostre compte a continuació" }, "error": { - "title": "Codi d'error: {{code}}" + "title": "Codi d'error: {{code}}", + "heading": "Error {{code}}: {{message}}", + "message": "Ups! Sembla que no hem trobat la pàgina que busques.Comprova l'enllaç o torna-ho a provar més tard", + "no_access": "No estas autoritzat a accedir a aquesta aplicació ({{code}})", + "message_web": "Veure l'estat actual del servidor.O bé, torna a la pàgina d'inici", + "error_details": "ID de sol·licitud: {{id}}" } } diff --git a/apps/juxtaposition-ui/src/assets/locales/cs.json b/apps/juxtaposition-ui/src/assets/locales/cs.json index ee2441bc..3fb0d0ad 100644 --- a/apps/juxtaposition-ui/src/assets/locales/cs.json +++ b/apps/juxtaposition-ui/src/assets/locales/cs.json @@ -75,10 +75,12 @@ "language": "Čeština", "notifications": { "none": "Žádná oznámení.", - "new_follower/one": " Vás začali sledovat!", - "new_follower/two": ", Vás začali sledovat!", - "new_follower/three": ", , a {{count_other}} dalších Vás začali sledovat!", - "new_follower/multiple": ", , a {{count_other}} dalších Vás začali sledovat!" + "new_follower": { + "message/one": " Vás začali sledovat!", + "message/two": ", Vás začali sledovat!", + "message/three": ", , a {{count_other}} dalších Vás začali sledovat!", + "message/multiple": ", , a {{count_other}} dalších Vás začali sledovat!" + } }, "new_post": { "swearing": "Příspěvek nesmí obsahovat nevhodné výrazy.", @@ -194,4 +196,4 @@ "silently_delete_post": "Potichu smazat", "moderate_user": "Moderovat uživatele" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/de.json b/apps/juxtaposition-ui/src/assets/locales/de.json index 038fda7b..00cf3969 100644 --- a/apps/juxtaposition-ui/src/assets/locales/de.json +++ b/apps/juxtaposition-ui/src/assets/locales/de.json @@ -26,7 +26,7 @@ }, "all_communities": { "text": "Alle Communitys", - "popular_places": "Beliebte Orte", + "popular_places": "Beliebte Communities", "new_communities": "Neue Communitys" }, "community": { @@ -39,7 +39,8 @@ "tags_not_applicable": "nicht verfügbar", "related": "Ähnliche Communities", "related_to": "Ähnliche Communities wie {{community}}", - "closed": "In dieser Community können keine neuen Beiträge erstellt werden." + "closed": "In dieser Community können keine neuen Beiträge erstellt werden.", + "related_short": "Verwandte" }, "user_page": { "country": "Land", @@ -55,7 +56,12 @@ "settings": "Einstellungen", "deleted": "Gelöschter Nutzer", "banned": "Gesperrter Nutzer", - "friend_requests": "Anfragen" + "friend_requests": "Anfragen", + "not_found": "Nutzer existiert nicht", + "tester_tag": "Tester*In", + "supporter_tag": "Unterstützer*In", + "moderator_tag": "Moderator*In", + "developer_tag": "Entwickler*In" }, "user_settings": { "profile_settings": "Profileinstellungen", @@ -64,17 +70,20 @@ "show_game": "Spielerfahrung im Profil anzeigen", "save_action": "Einstellungen speichern", "gdpr_download": "Nutzerdaten herunterladen", - "gdpr_download_action": "Herunterladen" + "gdpr_download_action": "Herunterladen", + "show_profile": "Zeige Profil Gästen" }, "activity_feed": { "empty": "Es ist ganz schön leer hier. Versuche, jemandem zu folgen!" }, "notifications": { "none": "Keine Benachrichtigungen.", - "new_follower/one": " folgt dir jetzt!", - "new_follower/two": " und folgen dir jetzt!", - "new_follower/three": ", und {{count_other}} weitere folgen dir jetzt!", - "new_follower/multiple": ", und {{count_other}} weitere folgen dir jetzt!" + "new_follower": { + "message/one": " folgt dir jetzt!", + "message/two": " und folgen dir jetzt!", + "message/three": ", und {{count_other}} weitere folgen dir jetzt!", + "message/multiple": ", und {{count_other}} weitere folgen dir jetzt!" + } }, "new_post": { "post_to": "Beitrag posten bei {{user}}", @@ -85,14 +94,15 @@ "no_screenshot": "Kein Screenshot", "spoiler_label": "Spoiler", "painting_close": "Abbrechen", - "painting_submit": "OK" + "painting_submit": "OK", + "automod_error": "Dein Beitrag beinhaltet Text, welcher nicht auf Juxtaposition erlaubt ist. Für mehr Informationen besuche bitte https://preten.do/juxt-rules" }, "setup": { "welcome": "Willkommen bei Juxtaposition!", "welcome_text": "Juxt ist eine Gaming-Community, die Menschen aus der ganzen Welt mit Mii-Charakteren verbindet. Nutze Juxt, um deine Spielerlebnisse zu teilen und Leute aus der ganzen Welt zu treffen.", "beta": "Beta-Haftungsausschluss", "beta_text": { - "first": "Du bist dabei, die erste öffentliche Beta von Juxt auszuprobieren. Das heißt, es liegt noch viel in der Luft, vieles kann sich jederzeit ändern.", + "first": "Willkommen zur öffentlichen Beta von Pretendo Network! Seit der ersten öffentlichen Beta hat sich einiges geändert, Juxt ist immer noch in aktiver Entwicklung und es kann sich einiges in kurzer Zeit ändern.", "second": "Dies kann und wird eine vollständige Löschung der Datenbank am Ende oder während des Beta-Zeitraums beinhalten.", "third": "Die Website, ihre Software und alle darauf enthaltenen Inhalte werden „wie besehen“ und „wie verfügbar“ bereitgestellt. Das Pretendo Network gibt keine ausdrücklichen oder stillschweigenden Garantien hinsichtlich der Eignung oder Nutzbarkeit der Website, ihrer Software oder ihres Inhalts." }, @@ -144,7 +154,7 @@ "title": "Error {{code}}", "heading": "Error {{code}}: {{message}}", "message": "Hoppla! Die gesuchte Seite konnte leider nicht gefunden werden.Überprüfe bitte deinen Link oder versuche es später noch einmal.", - "no_access": "Sie sind nicht berechtigt, auf diese Anwendung zuzugreifen ({{code}}).", + "no_access": "Sie sind nicht berechtigt auf diese Anwendung zuzugreifen ({{code}})", "message_web": "Serverstatus anzeigen.Oder zurück zur Startseite.", "error_details": "Anfrage-ID: {{id}}" }, @@ -155,7 +165,7 @@ "title": "Beitrag von {{username}}", "heading": "Beitrag", "yeahs_count/one": " Person hat diesem Beitrag ein „Yeah“ gegeben.", - "yeahs_count/multiple": " Person hat diesem Beitrag ein „Yeah“ gegeben.", + "yeahs_count/multiple": " Personen haben diesem Beitrag ein „Yeah“ gegeben.", "show_spoiler": "Spoiler anzeigen", "reply_post": "Antworten", "report_post": "Beitrag melden", @@ -165,7 +175,7 @@ }, "reporting": { "title": "Beitrag melden", - "submit": "Bericht senden", + "submit": "Meldung senden", "description": "Du bist dabei, einen Beitrag zu melden, dessen Inhalt gegen den Juxtaposition-Verhaltenskodex verstößt. Diese Meldung wird an die Juxtaposition-Administratoren von Pretendo gesendet und nicht an den Ersteller des Beitrags.", "label": "Grund:", "additional_info_placeholder": "Zusätzliche Kommentare oder Informationen eingeben", @@ -178,11 +188,12 @@ "reason_nsfw": "Sexuell explizit", "reason_piracy": "Piraterie", "reason_inappropiate_ingame": "Unangemessenes Verhalten im Spiel", - "reason_missing_images": "Fehlende Bilder" + "reason_missing_images": "Fehlende Bilder", + "reason_others": "Andere Gründe" }, "moderation": { "title": "Moderation", "silently_delete_post": "Geräuschlos löschen", "moderate_user": "Nutzer moderieren" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/en.json b/apps/juxtaposition-ui/src/assets/locales/en.json index 06429d1d..2249239d 100644 --- a/apps/juxtaposition-ui/src/assets/locales/en.json +++ b/apps/juxtaposition-ui/src/assets/locales/en.json @@ -97,10 +97,26 @@ }, "notifications": { "none": "No notifications.", - "new_follower/one": " followed you!", - "new_follower/two": ", followed you!", - "new_follower/three": ", , and {{count_other}} other followed you!", - "new_follower/multiple": ", , and {{count_other}} others followed you!" + "new_follower": { + "message/one": " followed you!", + "message/two": ", followed you!", + "message/three": ", , and {{count_other}} other followed you!", + "message/multiple": ", , and {{count_other}} others followed you!" + }, + "post_deleted": { + "post_removed": "Your post \"{{postId}}\" has been removed.", + "post_removed_for_reason": "Your post \"{{postId}}\" has been removed for the following reason: \"{{reason}}\".", + "comment_removed": "Your comment \"{{postId}}\" has been removed.", + "comment_removed_for_reason": "Your comment \"{{postId}}\" has been removed for the following reason: \"{{reason}}\".", + "footer": "Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum ({{contactModsUrl}})." + }, + "limited_from_posting": { + "message": "You have been Limited from Posting.", + "message_with_reason": "You have been Limited from Posting. Reason: \"{{reason}}\".", + "temporary": "You have been Limited from Posting until {{until}}.", + "temporary_with_reason": "You have been Limited from Posting until {{until}}. Reason: \"{{reason}}\".", + "footer": "Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum ({{banAppealUrl}})." + } }, "friend_requests": { "none": "No Friend Requests" @@ -127,7 +143,10 @@ "report_post": "Report post", "delete_post": "Delete post", "removed": "Post has been removed.", - "copy_link": "Copy link" + "copy_link": "Copy link", + "sort_newest_first": "Sort by newest", + "sort_oldest_first": "Sort by oldest", + "replies_heading": "Replies" }, "setup": { "title": "Juxtaposition Setup", @@ -174,7 +193,7 @@ "reporting": { "title": "Report Post", "submit": "Submit Report", - "description": "You are about to report a post with content which violates the Juxtaposition Code of Conduct. This report will be sent to Pretendo's Juxtaposition administrators and not to the creator of the post.", + "description": "You are about to report a post that violates the Juxtaposition Code of Conduct. This report will be sent only to Juxtaposition Moderators and not to the creator of the post.", "label": "Reason:", "additional_info_placeholder": "Enter additional comments or information", "reason_spoiler": "Spoiler", @@ -187,11 +206,26 @@ "reason_piracy": "Piracy", "reason_inappropiate_ingame": "Inappropriate Behavior in Game", "reason_missing_images": "Missing Images", - "reason_others": "Other" + "reason_others": "Other", + "reason_not_nice": "Mean/Rude/Hateful (Rule 1)", + "reason_inappropriate": "Inappropriate/NSFW (Rule 2)", + "reason_spam": "Spam/Self-Promotion (Rule 3)", + "reason_offtopic": "Off-Topic (Rule 4)", + "reason_piracy_new": "Piracy (Rule 5)", + "reason_exploits": "API Abuse/Exploiting Bugs (Rule 8)", + "reason_drama": "Drama (Rule 9)", + "reason_cheating": "Cheating Online (Rule 10)", + "reason_spoiler_new": "Spoiler (Rule 11)", + "reason_personal_info_new": "Personal Information (Rule 12)", + "reason_politics": "Politics (Rule 13)", + "reason_misinformation": "Misinformation/Bad Advice (Rule 14)", + "reason_impersonation": "Impersonation (Rule 15)" }, "moderation": { "title": "Moderation", "silently_delete_post": "Silently delete", + "mark_as_spoiler": "Mark as spoiler", + "mark_as_not_spoiler": "Remove spoiler", "moderate_user": "Moderate User" } } \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/es.json b/apps/juxtaposition-ui/src/assets/locales/es.json index f84f6cee..4f15c316 100644 --- a/apps/juxtaposition-ui/src/assets/locales/es.json +++ b/apps/juxtaposition-ui/src/assets/locales/es.json @@ -39,7 +39,8 @@ "related": "Otras comunidades", "related_to": "Otras comunidades de {{community}}", "closed": "Esta comunidad está cerrada a nuevos mensajes.", - "tags_not_applicable": "N/A" + "tags_not_applicable": "N/A", + "related_short": "Relacionado" }, "user_page": { "country": "País", @@ -55,7 +56,9 @@ "game_experience_unknown": "Desconocido", "settings": "Ajustes", "deleted": "Usuario eliminado", - "banned": "Usuario restringido" + "banned": "Usuario restringido", + "not_found": "Este usuario no existe", + "tester_tag": "Tester" }, "user_settings": { "profile_settings": "Ajustes del perfil", @@ -71,10 +74,12 @@ }, "notifications": { "none": "Sin notificaciones.", - "new_follower/one": "¡ te ha seguido!", - "new_follower/two": "¡ y te han seguido!", - "new_follower/three": "¡, , y {{count_other}} más te han seguido!", - "new_follower/multiple": "¡, , y otros {{count_other}} te han seguido!" + "new_follower": { + "message/one": "¡ te ha seguido!", + "message/two": "¡ y te han seguido!", + "message/three": "¡, , y {{count_other}} más te han seguido!", + "message/multiple": "¡, , y otros {{count_other}} te han seguido!" + } }, "new_post": { "post_to": "Mensaje para \"{{user}}\"", @@ -185,4 +190,4 @@ "title": "Moderación", "silently_delete_post": "Eliminar en silencio" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/fr.json b/apps/juxtaposition-ui/src/assets/locales/fr.json index 01d7256b..b7659c78 100644 --- a/apps/juxtaposition-ui/src/assets/locales/fr.json +++ b/apps/juxtaposition-ui/src/assets/locales/fr.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Aucune notification.", - "new_follower/one": " vous suit !", - "new_follower/two": " et vous suivent !", - "new_follower/three": ", et {{count_other}} autre personne vous suivent !", - "new_follower/multiple": ", et {{count_other}} autres personnes vous suivent !" + "new_follower": { + "message/one": " vous suit !", + "message/two": " et vous suivent !", + "message/three": ", et {{count_other}} autre personne vous suivent !", + "message/multiple": ", et {{count_other}} autres personnes vous suivent !" + } }, "new_post": { "post_to": "Envoyer à {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Supprimer discrètement", "moderate_user": "Modérer l'utilisateur" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/he.json b/apps/juxtaposition-ui/src/assets/locales/he.json index a8f6e14c..5629a7cd 100644 --- a/apps/juxtaposition-ui/src/assets/locales/he.json +++ b/apps/juxtaposition-ui/src/assets/locales/he.json @@ -109,10 +109,12 @@ }, "notifications": { "none": "אין התראות.", - "new_follower/one": "‏ עקב אחריכם!", - "new_follower/two": "‏,‏‏ עקבו אחריכם!", - "new_follower/three": "‏, ‏ ו-{{count_other}} נוסף עקבו אחריכם!", - "new_follower/multiple": "‏, ‏ ו-{{count_other}} נוספים עקבו אחריכם!" + "new_follower": { + "message/one": "‏ עקב אחריכם!", + "message/two": "‏,‏‏ עקבו אחריכם!", + "message/three": "‏, ‏ ו-{{count_other}} נוסף עקבו אחריכם!", + "message/multiple": "‏, ‏ ו-{{count_other}} נוספים עקבו אחריכם!" + } }, "new_post": { "swearing": "הפוסט לא יכול להכיל שפה בוטה.", @@ -184,4 +186,4 @@ "title": "ניהול", "silently_delete_post": "מחיקה שקטה" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/hr.json b/apps/juxtaposition-ui/src/assets/locales/hr.json index fdeae6c1..0cf56693 100644 --- a/apps/juxtaposition-ui/src/assets/locales/hr.json +++ b/apps/juxtaposition-ui/src/assets/locales/hr.json @@ -69,10 +69,12 @@ }, "notifications": { "none": "Nema obavijesti.", - "new_follower/one": " te je pratiol/la!", - "new_follower/two": ", su te pratili!", - "new_follower/three": ", i {{count_other}} druga pratitelja su te pratili!", - "new_follower/multiple": ", i {{count_other}} drugih pratitelja su te pratili!" + "new_follower": { + "message/one": " te je pratiol/la!", + "message/two": ", su te pratili!", + "message/three": ", i {{count_other}} druga pratitelja su te pratili!", + "message/multiple": ", i {{count_other}} drugih pratitelja su te pratili!" + } }, "new_post": { "post_to": "Objava za {{user}}", @@ -189,4 +191,4 @@ "silently_delete_post": "Tiho brisanje", "moderate_user": "Moderiraj korisnika" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/hu.json b/apps/juxtaposition-ui/src/assets/locales/hu.json index 50a62498..4a66843d 100644 --- a/apps/juxtaposition-ui/src/assets/locales/hu.json +++ b/apps/juxtaposition-ui/src/assets/locales/hu.json @@ -74,10 +74,12 @@ }, "notifications": { "none": "Nincsennek értesítések.", - "new_follower/one": " bekövetett téged!", - "new_follower/two": ", bekövetett téged!", - "new_follower/three": ", és {{count_other}} további felhasználó bekövetett téged!", - "new_follower/multiple": ", és {{count_other}} további felhasználó bekövetett téged!" + "new_follower": { + "message/one": " bekövetett téged!", + "message/two": ", bekövetett téged!", + "message/three": ", és {{count_other}} további felhasználó bekövetett téged!", + "message/multiple": ", és {{count_other}} további felhasználó bekövetett téged!" + } }, "new_post": { "post_to": "Bejegyzés ehhez {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Törlés csendben", "moderate_user": "Felhasználó moderálása" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/id.json b/apps/juxtaposition-ui/src/assets/locales/id.json index 49985412..464aeeb7 100644 --- a/apps/juxtaposition-ui/src/assets/locales/id.json +++ b/apps/juxtaposition-ui/src/assets/locales/id.json @@ -120,10 +120,12 @@ }, "notifications": { "none": "Tidak ada notificasi.", - "new_follower/one": " mengikuti Anda!", - "new_follower/two": ", mengikuti Anda!", - "new_follower/three": ", , dan {{count_other}} lainnya mengikuti Anda!", - "new_follower/multiple": ", , dan {{count_other}} lainnya mengikuti Anda!" + "new_follower": { + "message/one": " mengikuti Anda!", + "message/two": ", mengikuti Anda!", + "message/three": ", , dan {{count_other}} lainnya mengikuti Anda!", + "message/multiple": ", , dan {{count_other}} lainnya mengikuti Anda!" + } }, "new_post": { "post_to": "Posting ke {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Hapus diam-diam", "moderate_user": "Moderasi Pengguna" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/it.json b/apps/juxtaposition-ui/src/assets/locales/it.json index 7a93860a..3f9d740e 100644 --- a/apps/juxtaposition-ui/src/assets/locales/it.json +++ b/apps/juxtaposition-ui/src/assets/locales/it.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Nessuna notifica.", - "new_follower/one": " ti segue!", - "new_follower/two": " e ti seguono!", - "new_follower/three": ", e {{count_other}} altro ti seguono!", - "new_follower/multiple": ", e altri {{count_other}} ti seguono!" + "new_follower": { + "message/one": " ti segue!", + "message/two": " e ti seguono!", + "message/three": ", e {{count_other}} altro ti seguono!", + "message/multiple": ", e altri {{count_other}} ti seguono!" + } }, "new_post": { "post_to": "Posta su {{user}}", @@ -185,4 +187,4 @@ "silently_delete_post": "Elimina senza notifica", "moderate_user": "Modera utente" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/ko.json b/apps/juxtaposition-ui/src/assets/locales/ko.json index 97c55aba..c8519e14 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ko.json +++ b/apps/juxtaposition-ui/src/assets/locales/ko.json @@ -61,10 +61,12 @@ }, "notifications": { "none": "알림 없음.", - "new_follower/one": "이(가) 당신을 팔로우했어요!", - "new_follower/two": ", 이(가) 당신을 팔로우했어요!", - "new_follower/three": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!", - "new_follower/multiple": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!" + "new_follower": { + "message/one": "이(가) 당신을 팔로우했어요!", + "message/two": ", 이(가) 당신을 팔로우했어요!", + "message/three": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!", + "message/multiple": ", , 그리고 {{count_other}}명이 당신을 팔로우했어요!" + } }, "new_post": { "post_to": "{{user}}에게 게시물 달기", @@ -152,4 +154,4 @@ "reason_inappropiate_ingame": "게임에서의 부적절한 행위", "reason_missing_images": "이미지가 없음" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/nl.json b/apps/juxtaposition-ui/src/assets/locales/nl.json index 325b5d00..fcf02041 100644 --- a/apps/juxtaposition-ui/src/assets/locales/nl.json +++ b/apps/juxtaposition-ui/src/assets/locales/nl.json @@ -71,10 +71,12 @@ }, "notifications": { "none": "Geen meldingen.", - "new_follower/one": " volgt jou!", - "new_follower/two": ", volgen jou!", - "new_follower/three": ", , and {{count_other}} volgen jou!", - "new_follower/multiple": ", , en {{count_other}} volgen jou!" + "new_follower": { + "message/one": " volgt jou!", + "message/two": ", volgen jou!", + "message/three": ", , and {{count_other}} volgen jou!", + "message/multiple": ", , en {{count_other}} volgen jou!" + } }, "new_post": { "post_to": "Post in {{user}}", @@ -185,4 +187,4 @@ "silently_delete_post": "Stilzwijgend verwijderen", "moderate_user": "Gemiddelde gebruiker" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pl.json b/apps/juxtaposition-ui/src/assets/locales/pl.json index 58ea4cb0..27a8cd78 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pl.json +++ b/apps/juxtaposition-ui/src/assets/locales/pl.json @@ -117,9 +117,11 @@ }, "notifications": { "none": "Brak powiadomień.", - "new_follower/one": " obserwuje cię!", - "new_follower/two": ", obserwują cię!", - "new_follower/three": ", i {{count_other}} innych obserwuje cię!" + "new_follower": { + "message/one": " obserwuje cię!", + "message/two": ", obserwują cię!", + "message/three": ", i {{count_other}} innych obserwuje cię!" + } }, "new_post": { "post_to": "Postuj do {{user}}", @@ -148,4 +150,4 @@ "yeahs_count/one": "Osoby, które zareagowały \"Nieźle\": ", "yeahs_count/multiple": "Osoby, które zareagowały \"Nieźle\": " } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt.json b/apps/juxtaposition-ui/src/assets/locales/pt.json index 82d2639d..024aaa9f 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu-o!", - "new_follower/two": ", seguiram-lo!", - "new_follower/three": ", e {{count_other}} outro seguiram-o!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram-o!" + "new_follower": { + "message/one": " seguiu-o!", + "message/two": ", seguiram-lo!", + "message/three": ", e {{count_other}} outro seguiram-o!", + "message/multiple": ", , e {{count_other}} outros seguiram-o!" + } }, "new_post": { "post_to": "Publicar em {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Apagar silenciosamente", "moderate_user": "Moderar Utilizador" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt_PT.json b/apps/juxtaposition-ui/src/assets/locales/pt_PT.json index 36b8dc68..c2e463a0 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt_PT.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt_PT.json @@ -65,10 +65,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu-o!", - "new_follower/two": ", seguiram-lo!", - "new_follower/three": ", e {{count_other}} outro seguiram-o!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram-o!" + "new_follower": { + "message/one": " seguiu-o!", + "message/two": ", seguiram-lo!", + "message/three": ", e {{count_other}} outro seguiram-o!", + "message/multiple": ", , e {{count_other}} outros seguiram-o!" + } }, "setup": { "rules_text": { @@ -194,4 +196,4 @@ "silently_delete_post": "Apagar silenciosamente", "moderate_user": "Moderar Utilizador" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/pt_br.json b/apps/juxtaposition-ui/src/assets/locales/pt_br.json index b55d7817..d0d20030 100644 --- a/apps/juxtaposition-ui/src/assets/locales/pt_br.json +++ b/apps/juxtaposition-ui/src/assets/locales/pt_br.json @@ -78,10 +78,12 @@ }, "notifications": { "none": "Sem notificações.", - "new_follower/one": " seguiu você!", - "new_follower/two": ", seguiram você!", - "new_follower/three": ", , e {{count_other}} outro seguiram você!", - "new_follower/multiple": ", , e {{count_other}} outros seguiram você!" + "new_follower": { + "message/one": " seguiu você!", + "message/two": ", seguiram você!", + "message/three": ", , e {{count_other}} outro seguiram você!", + "message/multiple": ", , e {{count_other}} outros seguiram você!" + } }, "new_post": { "post_to": "Postar em {{user}}", @@ -194,4 +196,4 @@ "silently_delete_post": "Deletar silenciosamente", "moderate_user": "Moderar Usuário" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/ru.json b/apps/juxtaposition-ui/src/assets/locales/ru.json index f64fa9e0..d9ef780e 100644 --- a/apps/juxtaposition-ui/src/assets/locales/ru.json +++ b/apps/juxtaposition-ui/src/assets/locales/ru.json @@ -7,14 +7,22 @@ "notifications": "Уведомления", "go_back": "Вернуться", "back": "Назад", - "yeahs": "Классно", + "yeahs": "Лайки", "more": "Загрузить больше сообщений", "no_posts": "Нет постов", "private": "Приватный", "close": "Закрыть", "save": "Сохранить", "exit": "Выход", - "next": "Следующий" + "next": "Следующий", + "my_feed": "Моя лента", + "global_feed": "Общая лента", + "global_feed_short": "Общая", + "people_feed": "Лента подписок", + "people_feed_short": "Подписки", + "friend_requests": "Запросы в друзья", + "updates": "Обновления", + "search": "Поиск..." }, "all_communities": { "text": "Все сообщества", @@ -26,7 +34,13 @@ "posts": "Публикации", "tags": "Ярлыки", "recent": "Недавние сообщения", - "popular": "Популярные сообщения" + "popular": "Популярные сообщения", + "followers_count": "Подписчики: {{count}}", + "tags_not_applicable": "Н/Д", + "related": "Связанные сообщества", + "related_short": "Связанные", + "related_to": "Сообщества, связанные с {{community}}", + "closed": "Это сообщество закрыто для новых публикаций." }, "user_page": { "country": "Страна", @@ -37,23 +51,49 @@ "following": "Подписан", "followers": "Подписчики", "follow_user": "Подписаться", - "following_user": "Подписан" + "following_user": "Подписан", + "game_experience_unknown": "Неизвестно", + "settings": "Настройки", + "friend_requests": "Запросы", + "deleted": "Удалённый пользователь", + "banned": "Заблокированный пользователь", + "not_found": "Пользователь не существует", + "tester_tag": "Тестировщик", + "supporter_tag": "Спонсор", + "moderator_tag": "Модератор", + "developer_tag": "Разработчик" }, "user_settings": { "profile_settings": "Настройки профиля", "show_country": "Показывать страну в профиле", "show_birthday": "Показывать дату рождения в профиле", - "show_game": "Показывать игровой опыт в профиле" + "show_game": "Показывать игровой опыт в профиле", + "show_profile": "Показывать профиль гостям", + "save_action": "Сохранить настройки", + "gdpr_download": "Скачать данные пользователя", + "gdpr_download_action": "Скачать" }, "activity_feed": { "empty": "Тут пусто. Попробуйте на кого-то подписаться!" }, "notifications": { - "none": "Нет уведомлений." + "none": "Нет уведомлений.", + "new_follower/one": " подписался(-ась) на вас!", + "new_follower/two": " и подписались на вас!", + "new_follower/three": ", и ещё {{count_other}} пользователь подписались на вас!", + "new_follower/multiple": ", и ещё {{count_other}} подп. подписались на вас!" }, "new_post": { "post_to": "Написать {{user}}", - "swearing": "Сообщение не должно иметь нецензурную лексику." + "swearing": "Сообщение не должно иметь нецензурную лексику.", + "automod_error": "Ваша публикация содержит текст, запрещённый в Juxtaposition. Для получения дополнительной информации, пожалуйста, посетите https://preten.do/juxt-rules", + "new_post_short": "Пост", + "content_placeholder": "Поделитесь своими мыслями в публикации для сообщества или своих подписчиков.", + "screenshots_coming_soon": "Скриншоты ещё не готовы. Загляните позже!", + "no_screenshot": "Без скриншота", + "spoiler_label": "Спойлер", + "painting_close": "Отмена", + "painting_submit": "ОК" }, "setup": { "welcome": "Добро пожаловать в Juxtaposition!", @@ -94,6 +134,68 @@ "ready": "Готовы начать использовать Juxt", "ready_text": "Сначала загляните в некоторые сообщества и посмотрите, о чём пишут люди со всего мира. Воспользуйтесь этой возможностью, чтобы познакомиться с Juxt. Возможно, по пути вы сделаете несколько новых открытий!", "done": "Веселитесь в Juxt!", - "done_button": "Вперёд!" + "done_button": "Вперёд!", + "title": "Настройка Juxtaposition" + }, + "login": { + "title": "Вход в Juxtaposition", + "heading": "Войти в Juxtaposition", + "sub_title": "Введите ниже данные своего аккаунта", + "username": "Имя", + "password": "Пароль", + "forgot_password": "Забыли пароль?", + "no_account": "Нет аккаунта?", + "login_action": "Войти", + "no_account_setup": "Создание аккаунта доступно только при наличии привязанной консоли Wii U или 3DS." + }, + "error": { + "title": "Ошибка {{code}}", + "heading": "Ошибка {{code}}: {{message}}", + "message": "Ой! Похоже, нам не удалось найти нужную страницу.Проверьте ссылку или повторите попытку позже", + "no_access": "У вас нет прав для доступа к этому приложению ({{code}})", + "message_web": "Посмотреть текущий статус сервера.Или вернуться на главную страницу", + "error_details": "ID запроса: {{id}}" + }, + "friend_requests": { + "none": "Нет заявок в друзья" + }, + "post": { + "title": "Публикация от {{username}}", + "heading": "Публикация", + "yeahs_count/one": " пользователь поставил отметку «Здорово».", + "yeahs_count/multiple": "Пользователей, которым это «Здорово!»: .", + "show_spoiler": "Показать спойлер", + "reply_post": "Ответить", + "report_post": "Пожаловаться", + "delete_post": "Удалить публикацию", + "removed": "Публикация была удалена.", + "copy_link": "Копировать ссылку", + "sort_newest_first": "Сначала новые", + "sort_oldest_first": "Сначала старые" + }, + "reporting": { + "reason_spoiler": "Спойлер", + "reason_personal_info": "Личные данные", + "reason_violence": "Жестокий контент", + "reason_inappropiate": "Недопустимое/вредоносное поведение", + "reason_bullying": "Оскорбления/травля", + "reason_advertising": "Реклама", + "reason_nsfw": "Материалы сексуального характера", + "reason_piracy": "Пиратство", + "reason_inappropiate_ingame": "Недопустимое поведение в игре", + "reason_missing_images": "Отсутствуют изображения", + "reason_others": "Другое", + "title": "Пожаловаться на публикацию", + "submit": "Отправить жалобу", + "description": "Вы собираетесь пожаловаться на публикацию, содержание которой нарушает Правила поведения в Juxtaposition. Эта жалоба будет отправлена администраторам Juxtaposition в Pretendo, а не автору публикации.", + "label": "Причина:", + "additional_info_placeholder": "Введите дополнительные комментарии или информацию»" + }, + "moderation": { + "moderate_user": "Применить меры к пользователю", + "mark_as_not_spoiler": "Удалить пометку спойлера", + "silently_delete_post": "Удалить незаметно", + "title": "Модерация", + "mark_as_spoiler": "Отметить как спойлер" } } diff --git a/apps/juxtaposition-ui/src/assets/locales/sk.json b/apps/juxtaposition-ui/src/assets/locales/sk.json index aab1f494..f38157dd 100644 --- a/apps/juxtaposition-ui/src/assets/locales/sk.json +++ b/apps/juxtaposition-ui/src/assets/locales/sk.json @@ -68,10 +68,12 @@ }, "notifications": { "none": "Žiadne upozornenia.", - "new_follower/one": " Vás začal/-a sledovať!", - "new_follower/two": ", Vás začali sledovať!", - "new_follower/three": ", , a {{count_other}} ďalší Vás začali sledovať!", - "new_follower/multiple": ", , a {{count_other}} ďalších Vás začali sledovať!" + "new_follower": { + "message/one": " Vás začal/-a sledovať!", + "message/two": ", Vás začali sledovať!", + "message/three": ", , a {{count_other}} ďalší Vás začali sledovať!", + "message/multiple": ", , a {{count_other}} ďalších Vás začali sledovať!" + } }, "new_post": { "swearing": "Príspevok nemôže obsahovať nevhodné výrazy.", @@ -185,4 +187,4 @@ "silently_delete_post": "Ticho vymazať", "moderate_user": "Moderovať Používateľa" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/zh.json b/apps/juxtaposition-ui/src/assets/locales/zh.json index 8d818457..3fee4b1f 100644 --- a/apps/juxtaposition-ui/src/assets/locales/zh.json +++ b/apps/juxtaposition-ui/src/assets/locales/zh.json @@ -57,7 +57,8 @@ "deleted": "已删除用户", "banned": "已禁止用户", "friend_requests": "请求", - "not_found": "用户不存在" + "not_found": "用户不存在", + "tester_tag": "测试用户" }, "user_settings": { "profile_settings": "个人资料设置", @@ -73,10 +74,12 @@ }, "notifications": { "none": "无通知。", - "new_follower/one": " 已开始关注你!", - "new_follower/two": " 已开始关注你!", - "new_follower/three": " 和 {{count_other}} 位其他用户开始关注你!", - "new_follower/multiple": " 和 {{count_other}} 位其他用户开始关注你!" + "new_follower": { + "message/one": " 已开始关注你!", + "message/two": " 已开始关注你!", + "message/three": " 和 {{count_other}} 位其他用户开始关注你!", + "message/multiple": " 和 {{count_other}} 位其他用户开始关注你!" + } }, "new_post": { "post_to": "发布到{{user}}", @@ -189,4 +192,4 @@ "silently_delete_post": "静默删除", "moderate_user": "审核用户" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json b/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json index b21b32b7..abb49535 100644 --- a/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json +++ b/apps/juxtaposition-ui/src/assets/locales/zh_Hant.json @@ -115,10 +115,12 @@ }, "notifications": { "none": "沒有通知。", - "new_follower/one": " 已開始追蹤你!", - "new_follower/two": " 已開始追蹤你!", - "new_follower/three": " 和另外 {{count_other}} 人已開始追蹤你!", - "new_follower/multiple": " 和另外 {{count_other}} 人已開始追蹤你!" + "new_follower": { + "message/one": " 已開始追蹤你!", + "message/two": " 已開始追蹤你!", + "message/three": " 和另外 {{count_other}} 人已開始追蹤你!", + "message/multiple": " 和另外 {{count_other}} 人已開始追蹤你!" + } }, "new_post": { "swearing": "貼文不能包含露骨內容。", @@ -189,4 +191,4 @@ "silently_delete_post": "靜默刪除", "moderate_user": "審核此使用者" } -} +} \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/config.ts b/apps/juxtaposition-ui/src/config.ts index 110d8b01..584db81d 100644 --- a/apps/juxtaposition-ui/src/config.ts +++ b/apps/juxtaposition-ui/src/config.ts @@ -38,22 +38,7 @@ const schema = z.object({ disableConsoleChecks: z.stringbool().default(false), /** The AES key to use for decrypting service tokens. Must match the account server's. */ aesKey: z.string(), - mongoose: z.object({ - uri: z.string() - }), - s3: z.object({ - endpoint: z.string(), - key: z.string(), - secret: z.string(), - bucket: z.string(), - region: z.string() - }), grpc: z.object({ - friends: z.object({ - host: z.string(), - port: z.string(), - apiKey: z.string() - }), account: z.object({ host: z.string(), port: z.string(), @@ -91,22 +76,7 @@ export const presets = { serverEnvironment: 'prod', disableConsoleChecks: true, aesKey: '1234567812345678123456781234567812345678123456781234567812345678', - mongoose: { - uri: 'mongodb://localhost:27017/miiverse?directConnection=true' - }, - s3: { - endpoint: 'http://localhost:9000', - key: 'minioadmin', - secret: 'minioadmin', - bucket: 'miiverse', - region: 'us-east-1' - }, grpc: { - friends: { - host: 'localhost', - port: 8124, - apiKey: '12345678123456781234567812345678' - }, account: { host: 'localhost', port: 8123, diff --git a/apps/juxtaposition-ui/src/database.js b/apps/juxtaposition-ui/src/database.js deleted file mode 100644 index db9a835e..00000000 --- a/apps/juxtaposition-ui/src/database.js +++ /dev/null @@ -1,91 +0,0 @@ -import mongoose from 'mongoose'; -import { CONTENT } from '@/models/content'; -import { ENDPOINT } from '@/models/endpoint'; -import { POST } from '@/models/post'; -import { SETTINGS } from '@/models/settings'; -import { REPORT } from '@/models/report'; -import { logger } from '@/logger'; -import { config } from '@/config'; - -let connection; -mongoose.set('strictQuery', true); - -async function connect() { - connection = mongoose.connection; - connection.on('connected', function () { - logger.info(`MongoDB connected ${this.name}`); - }); - connection.on('error', err => logger.error(err, 'Database connection error')); - connection.on('close', () => { - connection.removeAllListeners(); - }); - - await mongoose.connect(config.mongoose.uri); -} - -function verifyConnected() { - if (!connection) { - connect(); - } -} - -export function notBanned() { - return { account_status: { $in: [0, 1] } }; -} - -async function getPostByID(postID) { - verifyConnected(); - return POST.findOne({ - id: postID - }); -} - -async function getDuplicatePosts(pid, post, olderThanMs) { - verifyConnected(); - return POST.findOne({ - pid: pid, - body: post.body, - screenshot: post.screenshot, - painting: post.painting, - created_at: { - $gte: new Date(Date.now() - olderThanMs) - }, - parent: null, - removed: false - }); -} - -async function getEndPoint(accessLevel) { - verifyConnected(); - return ENDPOINT.findOne({ - server_access_level: accessLevel - }); -} - -async function getUserSettings(pid) { - verifyConnected(); - return SETTINGS.findOne({ pid: pid }); -} - -async function getUserContent(pid) { - verifyConnected(); - return CONTENT.findOne({ pid: pid }); -} - -async function getDuplicateReports(pid, postID) { - verifyConnected(); - return REPORT.findOne({ - reported_by: pid, - post_id: postID - }); -} - -export const database = { - connect, - getPostByID, - getDuplicatePosts, - getEndPoint, - getUserSettings, - getUserContent, - getDuplicateReports -}; diff --git a/apps/juxtaposition-ui/src/images.ts b/apps/juxtaposition-ui/src/images.ts deleted file mode 100644 index e63d69fe..00000000 --- a/apps/juxtaposition-ui/src/images.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { readFile } from 'node:fs/promises'; -import { AlphaAction, ColorType, FilterType, Gravity, ImageMagick, initializeImageMagick, Interlace, MagickColor, MagickColors, Orientation } from '@imagemagick/magick-wasm'; -import { deflate, inflate } from 'pako'; -import { logger } from '@/logger'; -import { uploadCDNAsset } from '@/util'; -import type { IMagickImage } from '@imagemagick/magick-wasm'; - -export type Painting = { - png: Buffer; - big: Buffer; - tgaz: Buffer; -}; - -function processPainting(image: IMagickImage): Painting { - image.crop(320, 120); - image.extent(320, 120); - // Paintings only have black pixels and white pixels - // Notifying the codec of this gets good compression gains! - image.colorType = ColorType.Bilevel; - // Remove EXIF whatever - image.strip(); - - // Tuned to match pngcrush on bilevel painting data - image.settings.setDefine('PNG', 'compression-level', 9); - image.settings.setDefine('PNG', 'compression-filter', 0); - image.settings.setDefine('PNG', 'compression-strategy', 1); - - return { - png: image.write('PNG', Buffer.from), - big: image.clone((image) => { - image.filterType = FilterType.Point; - image.resize(640, 240); - - return image.write('PNG', Buffer.from); - }), - tgaz: image.clone((image) => { - // Ingame TGA decoders don't support all the fun stuff. - image.colorType = ColorType.TrueColorAlpha; - // Only this orientation is supported. - image.orientation = Orientation.BottomLeft; - image.flip(); - - return image.write('TGA', (tga) => { - const tgaz = deflate(tga, { level: 6 }); - return Buffer.from(tgaz); - }); - }) - }; -} - -/** - * Processes, sanitises and converts BMP-format paintings. - * @param painting Buffer of BMP painting. - * @returns PNG and TGAZ-encoded paintings. - */ -export function processBmpPainting(painting: Buffer): Painting { - return ImageMagick.read(painting, 'BMP', processPainting); -} -/** - * Processes, sanitises and converts TGAZ-format paintings. - * @param painting Buffer of TGAZ painting. - * @returns PNG and TGAZ-encoded paintings. - */ -export function processTgazPainting(painting: Buffer): Painting { - const tga = inflate(painting); - return ImageMagick.read(tga, 'TGA', processPainting); -} -/** - * Autodetects a BMPZ or TGAZ painting based on the file header and processes it accordingly. - * This is used in the api, where 3DS uses BMPZ and Wii U uses TGAZ. - * @param painting Buffer of BMPZ or TGAZ painting. - * @returns PNG and TGAZ-encoded paintings. - */ -function processAutozPainting(painting: Buffer): Painting { - const data = inflate(painting); - // todo this sucks - if (data.length > 0 && data[0] == 66 /* 'B' */) { - return ImageMagick.read(data, 'BMP', processPainting); - } else { - return ImageMagick.read(data, 'TGA', processPainting); - } -} - -export type ProcessPaintingOptions = { - blob: string; - isBmp: boolean; - autodetectFormat: boolean; - pid: number; - postId: string; -}; -export type PaintingUrls = { - blob: string; - img: string; - big: string; -}; -/** - * Processes and uploads a new painting to the CDN - to paintings/${pid}/${postID}.png. - * @param paintingBlob base64 TGAZ or BMP blob from the client request body. - * @param bmp 'true' if the image should be decoded as BMP (as on 3DS). - * @param pid Poster PID. - * @param postID Post ID. - * @returns base64 TGAZ blob, sanitised. - */ -export async function uploadPainting(opts: ProcessPaintingOptions): Promise { - const paintingBuf = Buffer.from(opts.blob.replace(/\0/g, '').trim(), 'base64'); - const paintings = ((): Painting => { - if (opts.autodetectFormat) { - return processAutozPainting(paintingBuf); - } else if (opts.isBmp) { - return processBmpPainting(paintingBuf); - } else { - return processTgazPainting(paintingBuf); - } - })(); - - const imgKey = `paintings/${opts.pid}/${opts.postId}.png`; - const bigKey = `paintings/${opts.pid}/${opts.postId}-big.png`; - - if (!await uploadCDNAsset(imgKey, paintings.png, 'public-read')) { - return null; - } - - if (!await uploadCDNAsset(bigKey, paintings.big, 'public-read')) { - return null; - } - - return { - blob: paintings.tgaz.toString('base64'), - img: `/${imgKey}`, - big: `/${bigKey}` - }; -} - -export type Aspect = '16:9' | '5:3' | '4:3'; - -export type Screenshot = { - // Normal image - jpg: Buffer; - // Tiny image (for ctr) - thumb: Buffer; - // 2x image (for wiiu) - big: Buffer | null; - - aspect: Aspect; -}; - -type Res = { - w: number; - h: number; -}; -function res(w: number, h: number): Res { - return { w, h }; -} - -type ScreenshotRes = { - full: Res; - thumb: Res; - big: Res | null; - - aspect: Aspect; -}; - -const validResolutions: ScreenshotRes[] = [ - { full: res(800, 450), thumb: res(320, 180), big: null, aspect: '16:9' }, - { full: res(400, 240), thumb: res(320, 192), big: res(800, 480), aspect: '5:3' }, - { full: res(320, 240), thumb: res(320, 240), big: res(640, 480), aspect: '4:3' }, - { full: res(640, 480), thumb: res(320, 240), big: null, aspect: '4:3' } -]; - -function processScreenshot(image: IMagickImage): Screenshot | null { - const res = validResolutions.find(({ full }) => full.w === image.width && full.h === image.height); - if (res === undefined) { - return null; - } - const { thumb, big, aspect } = res; - // Remove EXIF whatever - image.strip(); - - image.quality = 90; - image.settings.setDefine('JPEG', 'sampling-factor', '2x2'); - image.settings.interlace = Interlace.Jpeg; - - // Remove alpha if input was an alpha-enabled BMP - image.backgroundColor = new MagickColor('white'); - image.alpha(AlphaAction.Remove); - - return { - jpg: image.write('JPEG', Buffer.from), - thumb: image.clone((image) => { - image.filterType = FilterType.Lanczos2; - image.resize(thumb.w, thumb.h); - image.extent(thumb.w, thumb.h, Gravity.Center); - - image.quality = 85; // smash 'em - return image.write('JPEG', Buffer.from); - }), - big: image.clone((image) => { - if (!big) { - return null; - } - - // the entire purpose of doing this is to get sharp pixels - image.filterType = FilterType.Point; - image.resize(big.w, big.h); - image.extent(big.w, big.h, Gravity.Center); - - return image.write('JPEG', Buffer.from); - }), - aspect - }; -} - -export function processAutoScreenshot(screenshot: Buffer): Screenshot | null { - const allowedFormats = [ - 'JPEG', // Used by Miiverse and Miiverse-enabled games - 'BMP', 'BMP3' // Used by un-official mods, such as CTGP-7 - ]; - return ImageMagick.read(screenshot, (img) => { - if (!allowedFormats.includes(img.format)) { - throw new Error(`Invalid format, got '${img.format}'`); - } - return processScreenshot(img); - }); -} - -export type ScreenshotUrls = { - full: string; - fullLength: number; - thumb: string; - big: string | null; - aspect: Aspect; -}; - -export type UploadScreenshotOptions = { - buffer?: Buffer; - blob: string; - pid: number; - postId: string; -}; -export async function uploadScreenshot(opts: UploadScreenshotOptions): Promise { - // try buffer, otherwise blob - const screenshotBuf = opts.buffer ?? Buffer.from(opts.blob.replace(/\0/g, '').trim(), 'base64'); - const screenshots = processAutoScreenshot(screenshotBuf); - if (screenshots === null) { - return null; - } - - const fullKey = `screenshots/${opts.pid}/${opts.postId}.jpg`; - const thumbKey = `screenshots/${opts.pid}/${opts.postId}-thumb.jpg`; - const bigKey = `screenshots/${opts.pid}/${opts.postId}-big.jpg`; - - const fullLength = screenshots.jpg.byteLength; - - if (!await uploadCDNAsset(fullKey, screenshots.jpg, 'public-read')) { - return null; - } - - if (!await uploadCDNAsset(thumbKey, screenshots.thumb, 'public-read')) { - return null; - } - - if (screenshots.big && !await uploadCDNAsset(bigKey, screenshots.big, 'public-read')) { - return null; - } - - const full = `/${fullKey}`; - const thumb = `/${thumbKey}`; - const big = screenshots.big ? `/${bigKey}` : null; - - return { full, fullLength, thumb, big, aspect: screenshots.aspect }; -} - -type Icon = { - icon32: Buffer; - icon48: Buffer; - icon64: Buffer; - icon96: Buffer; - icon128: Buffer; - tga: Buffer; -}; - -function processIcon(image: IMagickImage): Icon | null { - if (image.width < 128 || image.height !== image.width) { - return null; - } - image.colorType = ColorType.Palette; - image.strip(); - // Just feels right bro - image.settings.setDefine('PNG', 'compression-level', 9); - image.settings.setDefine('PNG', 'compression-filter', 0); - image.settings.setDefine('PNG', 'compression-strategy', 0); - - return { - icon32: image.clone((image) => { - image.resize(32, 32); - return image.write('PNG', Buffer.from); - }), - icon48: image.clone((image) => { - image.resize(48, 48); - return image.write('PNG', Buffer.from); - }), - icon64: image.clone((image) => { - image.resize(64, 64); - return image.write('PNG', Buffer.from); - }), - icon96: image.clone((image) => { - image.resize(96, 96); - return image.write('PNG', Buffer.from); - }), - icon128: image.clone((image) => { - image.resize(128, 128); - return image.write('PNG', Buffer.from); - }), - tga: image.clone((image) => { - image.resize(128, 128); - // Ingame TGA decoders don't support all the fun stuff. - image.colorType = ColorType.TrueColorAlpha; - // Only this orientation is supported. - image.orientation = Orientation.BottomLeft; - image.flip(); - - return image.write('TGA', (tga) => { - const tgaz = deflate(tga, { level: 6 }); - return Buffer.from(tgaz); - }); - }) - }; -} - -export type IconUrls = { - icon32: string; - icon48: string; - icon64: string; - icon96: string; - icon128: string; - tgaBlob: string; -}; -export type UploadIconsOptions = { - icon: Buffer; - communityId: string; -}; - -export async function uploadIcons(opts: UploadIconsOptions): Promise { - const icons = ImageMagick.read(opts.icon, 'PNG', processIcon); - if (icons === null) { - return null; - } - - const icon32Key = `icons/${opts.communityId}/32.png`; - const icon48Key = `icons/${opts.communityId}/48.png`; - const icon64Key = `icons/${opts.communityId}/64.png`; - const icon96Key = `icons/${opts.communityId}/96.png`; - const icon128Key = `icons/${opts.communityId}/128.png`; - - if (!await uploadCDNAsset(icon32Key, icons.icon32, 'public-read') || - !await uploadCDNAsset(icon48Key, icons.icon48, 'public-read') || - !await uploadCDNAsset(icon64Key, icons.icon64, 'public-read') || - !await uploadCDNAsset(icon96Key, icons.icon96, 'public-read') || - !await uploadCDNAsset(icon128Key, icons.icon128, 'public-read')) { - return null; - } - - const icon32 = `/${icon32Key}`; - const icon48 = `/${icon48Key}`; - const icon64 = `/${icon64Key}`; - const icon96 = `/${icon96Key}`; - const icon128 = `/${icon128Key}`; - - return { - icon32, - icon48, - icon64, - icon96, - icon128, - tgaBlob: icons.tga.toString('base64') - }; -} - -function processCtrHeader(image: IMagickImage): Buffer | null { - // full height banners are 220, non-title ones are 168 - if (image.width !== 400 || (image.height !== 220 && image.height !== 168)) { - return null; - } - - // add white pixels to get up to 220 - image.backgroundColor = MagickColors.White; - image.extent(400, 220, Gravity.North); - - image.strip(); - image.quality = 90; - image.settings.setDefine('JPEG', 'sampling-factor', '2x2'); - image.settings.interlace = Interlace.Jpeg; - - return image.write('JPEG', Buffer.from); -} - -function processWupHeader(image: IMagickImage): Buffer | null { - if (image.width !== 1280 || image.height !== 180) { - return null; - } - - image.strip(); - image.quality = 90; - image.settings.setDefine('JPEG', 'sampling-factor', '2x2'); - image.settings.interlace = Interlace.Jpeg; - - return image.write('JPEG', Buffer.from); -} - -export type HeaderUrls = { - ctr: string; - wup: string; -}; -export type UploadHeadersOptions = { - ctr_header: Buffer; - wup_header: Buffer; - communityId: string; -}; - -export async function uploadHeaders(opts: UploadHeadersOptions): Promise { - const ctr_processed = ImageMagick.read(opts.ctr_header, 'PNG', processCtrHeader); - const wup_processed = ImageMagick.read(opts.wup_header, 'PNG', processWupHeader); - if (ctr_processed === null || wup_processed == null) { - return null; - } - - const ctrKey = `headers/${opts.communityId}/3DS.jpg`; - const wupKey = `headers/${opts.communityId}/WiiU.jpg`; - - if (!await uploadCDNAsset(ctrKey, ctr_processed, 'public-read') || - !await uploadCDNAsset(wupKey, wup_processed, 'public-read')) { - return null; - } - - const ctr = `/${ctrKey}`; - const wup = `/${wupKey}`; - - return { - ctr, - wup - }; -} - -export async function initImageProcessing(): Promise { - const wasmPath = new URL(import.meta.resolve('@imagemagick/magick-wasm/magick.wasm')); - const wasm = await readFile(wasmPath); - await initializeImageMagick(wasm); - logger.success('Started up image processing engine'); -} diff --git a/apps/juxtaposition-ui/src/metrics.ts b/apps/juxtaposition-ui/src/metrics.ts index 2c7fa76f..0bb35a6e 100644 --- a/apps/juxtaposition-ui/src/metrics.ts +++ b/apps/juxtaposition-ui/src/metrics.ts @@ -1,133 +1,9 @@ -import { Gauge } from 'prom-client'; import expressMetrics from 'express-prom-bundle'; import express from 'express'; import { logger } from '@/logger'; import { config } from '@/config'; -import { SETTINGS } from '@/models/settings'; -import { POST } from '@/models/post'; import type { Express, NextFunction, Request, Response } from 'express'; -export const onlineNowGauge = new Gauge({ - name: 'juxtaposition_online_users_now', - help: 'Users active in the last 10 minutes', - async collect(): Promise { - const onlineRangeMs = 10 * 60 * 1000; // 10 minutes - const cutoff = new Date(Date.now() - onlineRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const activeMonthlyGauge = new Gauge({ - name: 'juxtaposition_active_users_monthly', - help: 'Users active in the last 30 days', - async collect(): Promise { - const monthlyRangeMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - monthlyRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const activeYearlyGauge = new Gauge({ - name: 'juxtaposition_active_users_yearly', - help: 'Users active in the last 365 days', - async collect(): Promise { - const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - yearlyRangeMs); - const [result] = await SETTINGS.aggregate<{ n: number } | undefined>([ - { $match: { last_active: { $gt: cutoff } } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const totalUsersGauge = new Gauge({ - name: 'juxtaposition_user_count_total', - help: 'Total number of registered users', - async collect(): Promise { - const count = await SETTINGS.estimatedDocumentCount(); - this.set(count); - } -}); - -export async function getUserMetrics(): Promise<{ totalUsers: number; currentOnlineUsers: number }> { - // This assumes that both gauges have no labels - return { - totalUsers: (await totalUsersGauge.get()).values[0].value, - currentOnlineUsers: (await onlineNowGauge.get()).values[0].value - }; -} - -export const dailyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_daily', - help: 'New posts in the last 24 hours', - async collect(): Promise { - const dailyRangeMs = 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - dailyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const monthlyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_monthly', - help: 'New posts in the last 30 days', - async collect(): Promise { - const monthyRangeMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - monthyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const yearlyPostGauge = new Gauge({ - name: 'juxtaposition_post_count_yearly', - help: 'New posts in the last 365 days', - async collect(): Promise { - const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; - const cutoff = new Date(Date.now() - yearlyRangeMs); - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export const totalPostsGauge = new Gauge({ - name: 'juxtaposition_post_count_total', - help: 'Total number of posts', - async collect(): Promise { - const [result] = await POST.aggregate<{ n: number } | undefined>([ - { $match: { message_to_pid: null } }, - { $count: 'n' } - ]); - this.set(result?.n ?? 0); - } -}); - -export async function getPostMetrics(): Promise<{ totalPosts: number; dailyPosts: number }> { - // This assumes that both gauges have no labels - return { - totalPosts: (await totalPostsGauge.get()).values[0].value, - dailyPosts: (await dailyPostGauge.get()).values[0].value - }; -} - export function registerMetrics(app: Express): Express { const metrics = express(); diff --git a/apps/juxtaposition-ui/src/middleware/discovery.tsx b/apps/juxtaposition-ui/src/middleware/discovery.tsx index 97b7354b..c5d23990 100644 --- a/apps/juxtaposition-ui/src/middleware/discovery.tsx +++ b/apps/juxtaposition-ui/src/middleware/discovery.tsx @@ -1,37 +1,38 @@ -import { database as db } from '@/database'; import { config } from '@/config'; import { WebLoginView } from '@/services/juxt-web/views/web/loginView'; import { PortalFatalErrorView } from '@/services/juxt-web/views/portal/errorView'; import { CtrFatalErrorView } from '@/services/juxt-web/views/ctr/errorView'; +import { createInternalApiClient } from '@/api/client'; import type { RequestHandler } from 'express'; +import type { DiscoveryStatus } from '@/api/generated'; + +export function getDiscoveryStatusMessage(status: DiscoveryStatus): string { + const messageMap: Record = { + closed: 'Juxtaposition is now closed. Thank you for your support!', + maintenance: 'Juxtaposition is currently under maintenance. Please try again later.', + open: 'Juxtaposition is open!', // unreachable + unavailable: 'Juxtaposition is currently unavailable. Please try again later.' + }; + return messageMap[status]; +} export const checkDiscovery: RequestHandler = async (request, response, next) => { - const discovery = await db.getEndPoint(config.serverEnvironment); + const api = createInternalApiClient({}); + const { data: discovery } = await api.discovery.get({ environment: config.serverEnvironment }); + const status = discovery.status; - if (!discovery || discovery.status !== 0) { - let message = ''; - const status = discovery ? discovery.status : -1; - switch (status) { - case 3: - message = 'Juxtaposition is currently under maintenance. Please try again later.'; - break; - case 4: - message = 'Juxtaposition is now closed. Thank you for your support!'; - break; - default: - message = 'Juxtaposition is currently unavailable. Please try again later.'; - break; - } + if (status !== 'open') { + const message = getDiscoveryStatusMessage(status); return response.jsxForDirectory({ web: , portal: , ctr: }); - } else { - request.guest_access = discovery ? discovery.guest_access : false; - request.new_users = discovery ? discovery.new_users : false; - response.locals.cdnURL = config.cdnDomain; } + request.guest_access = discovery.guestAccess; + request.new_users = discovery.newUsers; + response.locals.cdnURL = config.cdnDomain; + next(); }; diff --git a/apps/juxtaposition-ui/src/models/automodLog.ts b/apps/juxtaposition-ui/src/models/automodLog.ts deleted file mode 100644 index d19589cd..00000000 --- a/apps/juxtaposition-ui/src/models/automodLog.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { HydratedDocument } from 'mongoose'; - -export const automodAction = ['blocked', 'logged'] as const; -export type AutomodAction = (typeof automodAction)[number]; - -export type AutomodLogMatch = { - start: number; - end: number; -} & Document; - -export type AutomodLog = { - rule_id: string; - created_at: Date; - author: number; - action: AutomodAction; - post_id: string | null; - parent_post_id: string | null; - community_id: string | null; - matches: AutomodLogMatch[] | null; - post_content_body: string | null; -} & Document; - -export type HydratedAutomodLogDocument = HydratedDocument; - -export const automodLogMatchSchema = new Schema({ - start: { - type: Number, - required: true - }, - end: { - type: Number, - required: true - } -}); - -export const automodLogSchema = new Schema({ - rule_id: { - type: String, - required: true - }, - created_at: { - type: Date, - required: true - }, - action: { - type: String, - enum: automodAction, - required: true - }, - author: { - type: Number, - required: true - }, - post_id: { - type: String, - required: false - }, - parent_post_id: { - type: String, - required: false - }, - community_id: { - type: String, - required: false - }, - matches: { - type: [automodLogMatchSchema], - required: false - }, - post_content_body: { - type: String, - required: false - } -}); - -export const AutomodLog = model('AutomodLog', automodLogSchema); diff --git a/apps/juxtaposition-ui/src/models/automodRules.ts b/apps/juxtaposition-ui/src/models/automodRules.ts deleted file mode 100644 index 34987299..00000000 --- a/apps/juxtaposition-ui/src/models/automodRules.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { HydratedDocument } from 'mongoose'; - -export const automodRuleType = ['keyword'] as const; -export type AutomodRuleType = (typeof automodRuleType)[number]; - -export const automodRuleMode = ['block', 'log'] as const; -export type AutomodRuleMode = (typeof automodRuleMode)[number]; - -export type AutomodKeywordSettings = { - keywords: string[]; -} & Document; - -export type AutomodRule = { - enabled: boolean; - title: string; - description: string | null; - type: AutomodRuleType; - mode: AutomodRuleMode; - keyword_settings: { - keywords: string[]; - }; -} & Document; - -export type HydratedAutomodRuleDocument = HydratedDocument; - -export const automodRuleKeywordSettingsSchema = new Schema({ - keywords: { - type: [String], - required: true - } -}); - -export const automodRuleSchema = new Schema({ - enabled: { - type: Boolean, - required: true - }, - title: { - type: String, - required: true - }, - description: String, - type: { - type: String, - enum: automodRuleType, - required: true - }, - mode: { - type: String, - enum: automodRuleMode, - required: true - }, - keyword_settings: { - type: automodRuleKeywordSettingsSchema, - required: false - } -}); - -export const AutomodRule = model('AutomodRule', automodRuleSchema); diff --git a/apps/juxtaposition-ui/src/models/communities.ts b/apps/juxtaposition-ui/src/models/communities.ts deleted file mode 100644 index d4e3cc08..00000000 --- a/apps/juxtaposition-ui/src/models/communities.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { Model, HydratedDocument } from 'mongoose'; - -enum COMMUNITY_TYPE { - Main = 0, - Sub = 1, - Announcement = 2, - Private = 3 -} - -export interface IIconPaths { - 32: string; - 48: string; - 64: string; - 96: string; - 128: string; -} - -export interface ICommunityPermissions { - open: boolean; - minimum_new_post_access_level: number; - minimum_new_comment_access_level: number; - minimum_new_community_access_level: number; -} - -export const CommunityShotModes = ['allow', 'block', 'force'] as const; -export type CommunityShotMode = typeof CommunityShotModes[number]; - -/* This type needs to reflect "reality" as it is in the DB - * Thus, all the optionals, since some legacy documents are missing many fields - */ -export interface ICommunity { - platform_id: number; // int - name: string; - description: string; - open?: boolean; - allows_comments?: boolean; - type: COMMUNITY_TYPE; - parent?: string | null; // TFH community is undefined - admins?: number[]; - owner?: number; - created_at: Date; - empathy_count: number; - followers: number; - has_shop_page: number; - icon: string; - ctr_header?: string; - wup_header?: string; - icon_paths?: IIconPaths; - /** @deprecated Does not actually exist on any community. Use title_id */ - title_ids?: string[]; // Does not exist on any community - title_id: string[]; - community_id: string; - olive_community_id: string; - is_recommended: number; - app_data: string; - user_favorites?: number[]; - permissions: ICommunityPermissions; - shot_mode?: CommunityShotMode; - shot_extra_title_id?: string[]; -} -// Fields that have "default: " in the Mongoose schema should also be listed here to make them optional -// on input but not output -type CommunityDefaultedFields = - 'open' | - 'allows_comments' | - 'type' | - 'parent' | - 'admins' | - 'created_at' | - 'empathy_count' | - 'followers' | - 'has_shop_page' | - 'icon' | - 'title_ids' | - 'title_id' | - 'is_recommended' | - 'app_data' | - 'user_favorites' | - 'permissions' | - 'shot_mode' | - 'shot_extra_title_id'; -export type ICommunityInput = Omit & Partial>; - -export interface ICommunityMethods { - upFollower(): Promise; - downFollower(): Promise; -} - -export type CommunityModel = Model; -export type HydratedCommunityDocument = HydratedDocument; - -export const PermissionsSchema = new Schema({ - open: { - type: Boolean, - default: true - }, - minimum_new_post_access_level: { - type: Number, - default: 0 - }, - minimum_new_comment_access_level: { - type: Number, - default: 0 - }, - minimum_new_community_access_level: { - type: Number, - default: 3 - } -}); - -const IconPathsSchema = new Schema({ - 32: { - type: String, - required: true - }, - 48: { - type: String, - required: true - }, - 64: { - type: String, - required: true - }, - 96: { - type: String, - required: true - }, - 128: { - type: String, - required: true - } -}); - -/* Constraints here (default, required etc.) apply to new documents being added - * See ICommunity for expected shape of query results - * If you add default: or required:, please also update ICommunity and ICommunityInput! - * - * Temporarily exported for legacy InferSchemaType users - */ -export const CommunitySchema = new Schema({ - platform_id: { - type: Number, - required: true - }, - name: { - type: String, - required: true - }, - description: { - type: String, - required: true - }, - - open: { - type: Boolean, - default: true - }, - allows_comments: { - type: Boolean, - default: true - }, - /** - * 0: Main Community - * 1: Sub-Community - * 2: Announcement Community - * 3: Private Community - */ - type: { - type: Number, - default: 0 - }, - parent: { - type: String, - default: null - }, - admins: { - type: [Number], - default: undefined - }, - owner: Number, - created_at: { - type: Date, - default: new Date() - }, - empathy_count: { - type: Number, - default: 0 - }, - followers: { - type: Number, - default: 0 - }, - has_shop_page: { - type: Number, - default: 0 - }, - icon: { - type: String, - required: true - }, - ctr_header: { type: String }, - wup_header: { type: String }, - icon_paths: { type: IconPathsSchema }, - title_ids: { - type: [String], - default: undefined - }, - title_id: { - type: [String], - default: [] - }, - community_id: { - type: String, - required: true - }, - olive_community_id: { - type: String, - required: true - }, - is_recommended: { - type: Number, - default: 0 - }, - app_data: { - type: String, - default: '' - }, - user_favorites: { - type: [Number], - default: [] - }, - permissions: { - type: PermissionsSchema, - default: {} - }, - shot_mode: { - type: String, - enum: CommunityShotModes, - default: 'allow' - }, - shot_extra_title_id: { - type: [String], - default: [] - } -}); - -CommunitySchema.method('upFollower', async function () { - const followers = this.get('followers'); - this.set('followers', followers + 1); - - await this.save(); -}); - -CommunitySchema.method('downFollower', async function () { - const followers = this.get('followers'); - this.set('followers', followers - 1); - - await this.save(); -}); - -export const Community = model('Community', CommunitySchema); -export const COMMUNITY = Community; diff --git a/apps/juxtaposition-ui/src/models/content.js b/apps/juxtaposition-ui/src/models/content.js deleted file mode 100644 index 88d8cbae..00000000 --- a/apps/juxtaposition-ui/src/models/content.js +++ /dev/null @@ -1,55 +0,0 @@ -import { Schema, model } from 'mongoose'; - -export const ContentSchema = new Schema({ - pid: Number, - followed_communities: { - type: [String], - default: [] - }, - followed_users: { - type: [Number], - default: [] - }, - following_users: { - type: [Number], - default: [] - } -}); - -ContentSchema.methods.addToCommunities = async function (postID) { - const communities = this.get('followed_communities'); - communities.addToSet(postID); - await this.save(); -}; - -ContentSchema.methods.removeFromCommunities = async function (postID) { - const communities = this.get('followed_communities'); - communities.pull(postID); - await this.save(); -}; - -ContentSchema.methods.addToUsers = async function (postID) { - const users = this.get('followed_users'); - users.addToSet(postID); - await this.save(); -}; - -ContentSchema.methods.removeFromUsers = async function (postID) { - const users = this.get('followed_users'); - users.pull(postID); - await this.save(); -}; - -ContentSchema.methods.addToFollowers = async function (postID) { - const users = this.get('following_users'); - users.addToSet(postID); - await this.save(); -}; - -ContentSchema.methods.removeFromFollowers = async function (postID) { - const users = this.get('following_users'); - users.pull(postID); - await this.save(); -}; - -export const CONTENT = model('CONTENT', ContentSchema); diff --git a/apps/juxtaposition-ui/src/models/endpoint.ts b/apps/juxtaposition-ui/src/models/endpoint.ts deleted file mode 100644 index 13ebdf9d..00000000 --- a/apps/juxtaposition-ui/src/models/endpoint.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { HydratedDocument } from 'mongoose'; - -export type Endpoint = { - status: number; - server_access_level: string; - topics: boolean; - guest_access: boolean; - new_users: boolean; - host: string; - api_host: string; - portal_host: string; - n3ds_host: string; -} & Document; - -export type HydratedEndpointDocument = HydratedDocument; - -export const endpointSchema = new Schema({ - status: Number, - server_access_level: String, - topics: Boolean, - guest_access: Boolean, - new_users: { - type: Boolean, - default: true - }, - host: String, - api_host: String, - portal_host: String, - n3ds_host: String -}); - -export const Endpoint = model('Endpoint', endpointSchema); -export const ENDPOINT = Endpoint; diff --git a/apps/juxtaposition-ui/src/models/logs.js b/apps/juxtaposition-ui/src/models/logs.js deleted file mode 100644 index e1862bf3..00000000 --- a/apps/juxtaposition-ui/src/models/logs.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Schema, model } from 'mongoose'; - -const actionEnum = [ - 'REMOVE_POST', - 'IGNORE_REPORT', - 'LIMIT_POSTING', - 'TEMP_BAN', - 'PERMA_BAN', - 'UNBAN', - 'UPDATE_USER', - 'MAKE_COMMUNITY', - 'UPDATE_COMMUNITY', - 'DELETE_COMMUNITY' -]; - -export const auditLogSchema = new Schema({ - actor: { - type: Number, - required: true - }, - action: { - type: String, - enum: actionEnum, - required: true - }, - target: { - type: String, - required: true - }, - context: { - type: String, - required: true - }, - timestamp: { - type: Date, - default: Date.now, - required: true - }, - changed_fields: { - type: [String], - default: [], - required: true - } -}); - -export const LOGS = model('LOGS', auditLogSchema); diff --git a/apps/juxtaposition-ui/src/models/notifications.js b/apps/juxtaposition-ui/src/models/notifications.js deleted file mode 100644 index 792d2ae9..00000000 --- a/apps/juxtaposition-ui/src/models/notifications.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Schema, model } from 'mongoose'; - -export const NotificationSchema = new Schema({ - pid: String, - type: String, - link: String, - image: String, - text: String, - objectID: String, - users: [{ - user: String, - timestamp: Date - }], - read: Boolean, - lastUpdated: Date -}); - -NotificationSchema.methods.markRead = async function () { - this.set('read', true); - await this.save(); -}; - -export const NOTIFICATION = model('NOTIFICATION', NotificationSchema); diff --git a/apps/juxtaposition-ui/src/models/post.js b/apps/juxtaposition-ui/src/models/post.js deleted file mode 100644 index 59db4d9a..00000000 --- a/apps/juxtaposition-ui/src/models/post.js +++ /dev/null @@ -1,145 +0,0 @@ -import { Schema, model } from 'mongoose'; - -export const PostSchema = new Schema({ - id: String, - title_id: String, - screen_name: String, - body: String, - app_data: String, - painting: String, - painting_img: String, - painting_big: String, - screenshot: String, - screenshot_big: String, - screenshot_thumb: String, - screenshot_length: Number, - screenshot_aspect: String, - search_key: { - type: [String], - default: undefined - }, - topic_tag: { - type: String, - default: undefined - }, - community_id: { - type: String, - default: undefined - }, - created_at: Date, - feeling_id: Number, - is_autopost: { - type: Number, - default: 0 - }, - is_community_private_autopost: { - type: Number, - default: 0 - }, - is_spoiler: { - type: Number, - default: 0 - }, - is_app_jumpable: { - type: Number, - default: 0 - }, - empathy_count: { - type: Number, - default: 0, - min: 0 - }, - country_id: { - type: Number, - default: 49 - }, - language_id: { - type: Number, - default: 1 - }, - mii: String, - mii_face_url: String, - pid: Number, - platform_id: Number, - region_id: Number, - parent: String, - reply_count: { - type: Number, - default: 0 - }, - verified: { - type: Boolean, - default: false - }, - message_to_pid: { - type: String, - default: null - }, - removed: { - type: Boolean, - default: false - }, - removed_reason: String, - removed_by: Number, - removed_at: Date, - yeahs: [Number] -}); - -PostSchema.index({ - community_id: 1, - removed: 1, - search_key: 1, - is_spoiler: 1, - message_to_pid: 1, - created_at: -1 -}); - -PostSchema.index({ - yeahs: 1, - removed: 1, - created_at: -1 -}); - -PostSchema.index({ - parent: 1, - removed: 1 -}); - -// Index for post count on community page -PostSchema.index({ - community_id: 1, - removed: 1, - parent: 1 -}); - -// Topic tag lookups -PostSchema.index({ - topic_tag: 1, - parent: 1, - removed: 1, - created_at: -1 -}); - -PostSchema.methods.upReply = async function () { - const replyCount = this.get('reply_count'); - if (replyCount + 1 < 0) { - this.set('reply_count', 0); - } else { - this.set('reply_count', replyCount + 1); - } - - await this.save(); -}; - -PostSchema.methods.downReply = async function () { - const replyCount = this.get('reply_count'); - if (replyCount - 1 < 0) { - this.set('reply_count', 0); - } else { - this.set('reply_count', replyCount - 1); - } - - await this.save(); -}; - -export const POST = model('POST', PostSchema); diff --git a/apps/juxtaposition-ui/src/models/report.ts b/apps/juxtaposition-ui/src/models/report.ts deleted file mode 100644 index 1f52b36f..00000000 --- a/apps/juxtaposition-ui/src/models/report.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { HydratedDocument, Model } from 'mongoose'; - -export interface IReport { - pid: number; - reported_by: number; - post_id: string; - reason: number; - message: string; - created_at: Date; - resolved?: boolean | null; - note?: string | null; - resolved_by?: number | null; - resolved_at?: Date | null; -} - -export type ReportModel = Model; -export type HydratedReportDocument = HydratedDocument; - -export interface IReportMethods { - resolve(pid: number, note: string | null): Promise; -} - -export const ReportSchema = new Schema({ - pid: { - type: Number, - required: true - }, - reported_by: { - type: Number, - required: true - }, - post_id: { - type: String, - required: true - }, - reason: { - type: Number, - required: true - }, - message: { - type: String, - default: '' - }, - created_at: { - type: Date, - required: true, - default: new Date() - }, - resolved: { - type: Boolean, - default: false - }, - note: String, - resolved_by: Number, - resolved_at: Date -}); - -ReportSchema.method('resolve', async function (pid, note) { - this.set('resolved', true); - this.set('resolved_by', pid); - this.set('resolved_at', new Date()); - this.set('note', note); - await this.save(); -}); - -export const Report = model('Report', ReportSchema); -export const REPORT = Report; diff --git a/apps/juxtaposition-ui/src/models/settings.ts b/apps/juxtaposition-ui/src/models/settings.ts deleted file mode 100644 index 63d5cfc6..00000000 --- a/apps/juxtaposition-ui/src/models/settings.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { HydratedDocument, Model } from 'mongoose'; - -/* This type needs to reflect "reality" as it is in the DB - * Thus, all the optionals, since some legacy documents are missing many fields - */ -export interface ISettings { - pid: number; - screen_name: string; - account_status: number; - ban_lift_date?: Date | null; - banned_by?: number | null; - ban_reason?: string | null; - profile_comment?: string | null; - profile_comment_visibility: boolean; - game_skill: number; - game_skill_visibility: boolean; - birthday_visibility: boolean; - relationship_visibility: boolean; - country_visibility: boolean; - profile_favorite_community_visibility: boolean; - profile_visibility?: string; - receive_notifications: boolean; - created_at?: Date; - last_active?: Date; -} -// Fields that have "default: " in the Mongoose schema should also be listed here to make them optional -// on input but not output -type SettingsDefaultedFields = - 'account_status' | - 'ban_lift_date' | - 'banned_by' | - 'ban_reason' | - 'profile_comment' | - 'profile_comment_visibility' | - 'game_skill' | - 'game_skill_visibility' | - 'birthday_visibility' | - 'relationship_visibility' | - 'country_visibility' | - 'profile_favorite_community_visibility' | - 'profile_visibility' | - 'receive_notifications' | - 'created_at' | - 'last_active'; -export type ISettingsInput = Omit & Partial>; - -export interface ISettingsMethods { - updateComment(comment: string | null): Promise; -} - -export type SettingsModel = Model; -export type HydratedSettingsDocument = HydratedDocument; - -export const SettingsSchema = new Schema({ - pid: { type: Number, required: true }, - screen_name: { type: String, required: true }, - account_status: { - type: Number, - default: 0 - }, - // TODO: Move bans to their own collection. User settings should be scoped to older the user - ban_lift_date: { - type: Date, - default: null - }, - banned_by: { - type: Number, - default: null - }, - ban_reason: { - type: String, - default: null - }, - profile_comment: { - type: String, - default: null - }, - profile_comment_visibility: { - type: Boolean, - default: true - }, - game_skill: { - type: Number, - default: 0 - }, - game_skill_visibility: { - type: Boolean, - default: true - }, - birthday_visibility: { - type: Boolean, - default: false - }, - relationship_visibility: { - type: Boolean, - default: false - }, - country_visibility: { - type: Boolean, - default: false - }, - profile_favorite_community_visibility: { - type: Boolean, - default: true - }, - profile_visibility: { - type: String, - enum: ['public', 'users_only'], - default: 'public' - }, - receive_notifications: { - type: Boolean, - default: true - }, - created_at: { - type: Date, - default: Date.now() - }, - last_active: { - type: Date, - default: Date.now(), - index: true - } -}); - -SettingsSchema.method('updateComment', async function (comment) { - this.set('profile_comment', comment); - await this.save(); -}); - -export const Settings = model('Setting', SettingsSchema); -export const SETTINGS = Settings; diff --git a/apps/juxtaposition-ui/src/redis.ts b/apps/juxtaposition-ui/src/redis.ts new file mode 100644 index 00000000..35d399f4 --- /dev/null +++ b/apps/juxtaposition-ui/src/redis.ts @@ -0,0 +1,17 @@ +import redis from 'redis'; +import { logger } from '@/logger'; +import { config } from '@/config'; + +const { host, port } = config.redis; + +export const redisClient = redis.createClient({ + url: `redis://${host}:${port}` +}); + +redisClient.on('error', (error) => { + logger.error(error); +}); + +redisClient.on('connect', () => { + logger.success('Redis connected'); +}); diff --git a/apps/juxtaposition-ui/src/redisCache.ts b/apps/juxtaposition-ui/src/redisCache.ts deleted file mode 100644 index 3f6c04c0..00000000 --- a/apps/juxtaposition-ui/src/redisCache.ts +++ /dev/null @@ -1,50 +0,0 @@ -import redis from 'redis'; -import { logger } from '@/logger'; -import { config } from '@/config'; -const { host, port } = config.redis; - -export const redisClient = redis.createClient({ - url: `redis://${host}:${port}` -}); - -redisClient.on('error', (error) => { - logger.error(error); -}); - -redisClient.on('connect', () => { - logger.success('Redis connected'); -}); - -export async function redisSet(key: string, value: string, expireTime?: number): Promise { - if (!redisClient.isOpen) { - return false; - } - - await redisClient.set(key, value, { - expiration: expireTime - ? { - type: 'EX', - value: expireTime - } - : undefined - }); - return true; -} - -export async function redisGet(key: string): Promise { - if (!redisClient.isOpen) { - return null; - } - - const result = await redisClient.get(key); - return result; -} - -export async function redisRemove(key: string): Promise { - if (!redisClient.isOpen) { - return false; - } - - await redisClient.del(key); - return true; -} diff --git a/apps/juxtaposition-ui/src/server.ts b/apps/juxtaposition-ui/src/server.ts index 50706b3d..022fe4a1 100644 --- a/apps/juxtaposition-ui/src/server.ts +++ b/apps/juxtaposition-ui/src/server.ts @@ -3,15 +3,13 @@ import cookieParser from 'cookie-parser'; import session from 'express-session'; import { RedisStore } from 'connect-redis'; import methodOverride from 'method-override'; -import { database } from '@/database'; import { logger } from '@/logger'; import { loggerHttp } from '@/loggerHttp'; -import { redisClient } from '@/redisCache'; +import { redisClient } from '@/redis'; import { router } from '@/services/juxt-web'; import { healthzRouter } from '@/services/healthz'; import { config } from '@/config'; import { jsxRenderer } from '@/middleware/jsx'; -import { initImageProcessing } from '@/images'; import { loginWall } from '@/middleware/webAuth'; import { listenMetrics, registerMetrics } from '@/metrics'; import { i18nMiddleware } from '@/middleware/i18n'; @@ -104,10 +102,7 @@ async function main(): Promise { // Starts the server logger.info('Starting server'); - await database.connect(); - logger.success('Database connected'); await redisClient.connect(); - await initImageProcessing(); app.listen(port, '0.0.0.0', () => { logger.success(`Server started on port ${port}`); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index 825d7be0..ab3318a8 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -1,8 +1,7 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; -import { getReasonMap, updateCommunityHashForAdminCommunity } from '@/util'; -import { getPostMetrics, getUserMetrics } from '@/metrics'; +import { getReasonMap } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserListView } from '@/services/juxt-web/views/web/admin/userListView'; import { WebReportListView } from '@/services/juxt-web/views/web/admin/reportListView'; @@ -46,8 +45,7 @@ adminRouter.get('/accounts', async function (req, res) { offset }); - const userMetrics = await getUserMetrics(); - const postMetrics = await getPostMetrics(); + const { data: stats } = await req.api.admin.getStats(); res.jsxForDirectory({ web: ( @@ -56,9 +54,9 @@ adminRouter.get('/accounts', async function (req, res) { page={query.page} search={search} userCount={usersPage.total} - activeCount={userMetrics.currentOnlineUsers} - dailyPostCount={postMetrics.dailyPosts} - totalPostCount={postMetrics.totalPosts} + activeCount={stats.onlineUsers} + dailyPostCount={stats.dailyPosts} + totalPostCount={stats.totalPosts} /> ) }); @@ -172,7 +170,6 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo shotMode: body.shot_mode, shotModeExtraTitleIds: body.shot_extra_title_id }); - updateCommunityHashForAdminCommunity(outputCommunity); res.redirect(`/admin/communities/${outputCommunity.olive_community_id}`); }); @@ -245,7 +242,6 @@ adminRouter.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCo shotModeExtraTitleIds: body.shot_extra_title_id }); - updateCommunityHashForAdminCommunity(outputCommunity); res.redirect(`/admin/communities/${outputCommunity.olive_community_id}`); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx index 2766919d..c4cd7376 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/adminAutomod.tsx @@ -2,13 +2,15 @@ import express from 'express'; import { z } from 'zod'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebAutomodLogListView } from '@/services/juxt-web/views/web/admin/automodLogListView'; -import { automodRuleMode, automodRuleType } from '@/models/automodRules'; import { WebAutomodRuleListView } from '@/services/juxt-web/views/web/admin/automodRuleListView'; import { WebAutomodRuleCreateView } from '@/services/juxt-web/views/web/admin/automodRuleCreateView'; import { onOffSchema } from '@/services/juxt-web/routes/admin/admin'; import type { AutomodRuleListViewProps } from '@/services/juxt-web/views/web/admin/automodRuleListView'; import type { AutomodLogListViewProps } from '@/services/juxt-web/views/web/admin/automodLogListView'; +const automodRuleType = ['keyword'] as const; +const automodRuleMode = ['block', 'log'] as const; + export const adminAutomodRouter = express.Router(); adminAutomodRouter.get('/automod', async function (req, res) { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx index 02d02de9..d5042633 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx @@ -2,7 +2,6 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; import { config } from '@/config'; -import { database } from '@/database'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebCommunityListView, WebCommunityOverviewView } from '@/services/juxt-web/views/web/communityListView'; import { PortalCommunityListView, PortalCommunityOverviewView } from '@/services/juxt-web/views/portal/communityListView'; @@ -19,15 +18,15 @@ import { zodFallback } from '@/util'; import { CtrNewPostPage } from '@/services/juxt-web/views/ctr/newPostView'; import { PortalNewPostPage } from '@/services/juxt-web/views/portal/newPostView'; import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; -import { Community } from '@/models/communities'; import { getAllFromList } from '@/api/helpers'; import { WebSubCommunityView } from '@/services/juxt-web/views/web/subCommunityView'; +import { WebNewPostPage } from '@/services/juxt-web/views/web/newPostView'; +import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; import type { PostListViewProps } from '@/services/juxt-web/views/web/postList'; import type { CommunityViewProps } from '@/services/juxt-web/views/web/communityView'; import type { SubCommunityViewProps } from '@/services/juxt-web/views/portal/subCommunityView'; import type { CommunityListViewProps, CommunityOverviewViewProps } from '@/services/juxt-web/views/web/communityListView'; -import type { FollowAction, FollowActionEnum, Post } from '@/api/generated'; -import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; +import type { FollowAction, Post } from '@/api/generated'; const upload = multer({ dest: 'uploads/' }); export const communitiesRouter = express.Router(); @@ -158,7 +157,8 @@ communitiesRouter.get('/:communityID/create', async function (req, res) { }; res.jsxForDirectory({ ctr: , - portal: + portal: , + web: }); }); @@ -259,41 +259,28 @@ communitiesRouter.post('/follow', upload.none(), async function (req, res) { }) }); - const userContent = await database.getUserContent(auth().pid); - if (!userContent) { - res.send({ status: 403 }); - return; - } - const { data: community } = await req.api.communities.get({ id: body.id }); if (!community) { res.send({ status: 404 }); return; } - const dbCommunity = await Community.findOne({ olive_community_id: community.olive_community_id }); - if (!dbCommunity) { - throw new Error('Community gone after request'); - } + const self = auth().self; + const isFollowing = self.content.followed_communities.includes(community.olive_community_id); - // Pretty terrible use of `any` here, but database models aren't typed yet so I have to - const userFollowsCommunity = userContent.followed_communities.includes(community.olive_community_id); - let action: FollowActionEnum; - if (!userFollowsCommunity) { - await dbCommunity.upFollower(); - await (userContent as any).addToCommunities(community.olive_community_id); - action = 'follow'; + let result: FollowAction; + if (isFollowing) { + const { data: followResult } = await req.api.communities.unfollow({ id: body.id }); + result = followResult; } else { - await dbCommunity.downFollower(); - await (userContent as any).removeFromCommunities(community.olive_community_id); - action = 'unfollow'; + const { data: followResult } = await req.api.communities.follow({ id: body.id }); + result = followResult; } - const result: FollowAction = { - action, - follower_count: dbCommunity.followers, - id: dbCommunity.olive_community_id - }; - - res.send({ status: 200, ...result }); + res.send({ + status: 200, + id: result.id, + action: result.action === 'follow' ? 'follow' : 'unfollow', + follower_count: result.follower_count + }); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx index c544b2bc..665706a7 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/notifications.tsx @@ -1,6 +1,5 @@ import express from 'express'; import { z } from 'zod'; -import { getUserFriendRequestsIncoming } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebNotificationListView, WebNotificationWrapperView } from '@/services/juxt-web/views/web/notificationListView'; import { PortalNotificationListView, PortalNotificationWrapperView } from '@/services/juxt-web/views/portal/notificationListView'; @@ -54,18 +53,16 @@ notificationRouter.get('/my_news', async function (req, res) { }); notificationRouter.get('/friend_requests', async function (req, res) { - const { query, auth } = parseReq(req, { + const { query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional() }) }); - const now = new Date(); - const allRequests = (await getUserFriendRequestsIncoming(auth().pid)).reverse(); - const validRequests = allRequests.filter(request => new Date(Number(request.expires) * 1000) > new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000)); + const { data: requests } = await req.api.self.getFriendRequests(); const props: FriendRequestListViewProps = { - requests: validRequests + requests }; if (query.pjax) { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index ca7f7406..b6cf5aeb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -1,15 +1,7 @@ -import crypto from 'crypto'; import express from 'express'; import multer from 'multer'; import { z } from 'zod'; -import { config } from '@/config'; -import { database } from '@/database'; -import { uploadPainting, uploadScreenshot } from '@/images'; -import { logger } from '@/logger'; -import { POST } from '@/models/post'; -import { REPORT } from '@/models/report'; -import { redisRemove } from '@/redisCache'; -import { createRatelimit, evaluateAutomodRules, getInvalidPostRegex, getUserAccountData, performAutomodAction } from '@/util'; +import { getUserAccountData, createRatelimit } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebPostPageView } from '@/services/juxt-web/views/web/postPageView'; import { CtrPostPageView } from '@/services/juxt-web/views/ctr/postPageView'; @@ -19,12 +11,12 @@ import { PortalNewPostPage } from '@/services/juxt-web/views/portal/newPostView' import { PortalReportPostPage } from '@/services/juxt-web/views/portal/reportPostView'; import { CtrReportPostPage } from '@/services/juxt-web/views/ctr/reportPostView'; import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; -import { AutomodRule } from '@/models/automodRules'; +import { WebNewPostPage } from '@/services/juxt-web/views/web/newPostView'; +import { InternalApiError, wrapApi } from '@/api/errors'; import type { Request, Response } from 'express'; -import type { PaintingUrls } from '@/images'; -import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; -import type { EmpathyActionEnum } from '@/api/generated'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; +import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; +import type { EmpathyActionEnum, StandardSortEnum, Post, PostCreateBody } from '@/api/generated'; const storage = multer.memoryStorage(); const upload = multer({ storage }); export const postsRouter = express.Router(); @@ -67,23 +59,26 @@ postsRouter.get('/:post_id/oembed.json', async function (req, res) { }); const { data: post } = await req.api.posts.get({ post_id: params.post_id }); - if (!post) { + if (!post || post.moderation?.removed) { + return res.sendStatus(404); + } + const { data: author } = await req.api.users.getProfile({ id: post.author.pid }); + if (!author) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: post.community_id }); - const postPNID = await getUserAccountData(post.pid); + const { data: community } = await req.api.communities.get({ id: post.community?.olive_community_id ?? '' }); let img = {}; - if (post.painting !== '') { + if (post.painting) { img = { - thumbnail_url: `${res.locals.cdnURL}/paintings/${post.pid}/${post.id}.png`, + thumbnail_url: `${res.locals.cdnURL}/paintings/${author.pid}/${post.id}.png`, thumbnail_width: 320, thumbnail_height: 120 }; - } else if (post.screenshot_thumb !== '') { + } else if (post.screenshot) { img = { - thumbnail_url: `${res.locals.cdnURL}${post.screenshot_thumb}` + thumbnail_url: `${res.locals.cdnURL}${post.screenshot.imageUrlThumbnail}` }; } else { img = { @@ -96,10 +91,10 @@ postsRouter.get('/:post_id/oembed.json', async function (req, res) { const doc = { type: 'link', version: '1.0', - title: `${post.screen_name} (@${postPNID.username}) - ${community?.name}`, - description: post.body, - author_name: post.screen_name, - author_url: 'https://juxt.pretendo.network/users/show?pid=' + post.pid, + title: `${author.miiName} (@${author.username}) - ${community?.name}`, + description: post.body ?? '', + author_name: author.miiName, + author_url: 'https://juxt.pretendo.network/users/show?pid=' + author.pid, provider_name: 'Juxtaposition - Pretendo Network', provider_url: `https://juxt.pretendo.network`, ...img @@ -119,17 +114,15 @@ postsRouter.post('/empathy', yeahLimit, async function (req, res) { return res.sendStatus(404); } - const existingEmpathy = post.yeahs.indexOf(auth().pid) !== -1; + const existingEmpathy = post.yeahsBy.some(v => v.pid === auth().pid); const action: EmpathyActionEnum = !existingEmpathy ? 'add' : 'remove'; const { data: result } = await req.api.posts.changeEmpathy({ post_id: post.id, action }); if (result === null) { - res.send({ status: 423, id: post.id, count: post.empathy_count }); + res.send({ status: 423, id: post.id, count: post.stats.empathyCount }); return; } res.send({ status: 200, id: result.post_id, count: result.empathy_count }); - - await redisRemove(`${post.pid}_user_page_posts`); }); postsRouter.post('/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 }]), async function (req, res) { @@ -137,9 +130,12 @@ postsRouter.post('/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 } }); postsRouter.get('/:post_id', async function (req, res) { - const { params, hasAuth, auth } = parseReq(req, { + const { params, query, hasAuth, auth } = parseReq(req, { params: z.object({ post_id: z.string() + }), + query: z.object({ + sort: z.enum(['newest-first', 'oldest-first']).default('oldest-first') }) }); const self = hasAuth() ? auth().self : null; @@ -148,21 +144,26 @@ postsRouter.get('/:post_id', async function (req, res) { if (!post) { return res.redirect('/404'); } - if (post.parent) { - const { data: parent } = await req.api.posts.get({ post_id: post.parent }); + if (post.parentId) { + const { data: parent } = await req.api.posts.get({ post_id: post.parentId }); if (!parent) { return res.redirect('/404'); } return res.redirect(`/posts/${parent.id}`); } - const { data: community } = await req.api.communities.get({ id: post.community_id }); + + if (!post.community) { + return res.redirect('/404'); + } + const { data: community } = await req.api.communities.get({ id: post.community.olive_community_id }); if (!community) { return res.redirect('/404'); } // increase limit for post replies since there's no pagination yet - const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort: 'oldest', limit: 500 }))?.data.items ?? []; - const postPNID = await getUserAccountData(post.pid); + const sort: StandardSortEnum = query.sort === 'newest-first' ? 'newest' : 'oldest'; + const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort, limit: 500 }))?.data.items ?? []; + const postPNID = await getUserAccountData(post.author.pid); const canPost = !!self && isPostingAllowed(community, self, post); const props: PostPageViewProps = { @@ -171,7 +172,8 @@ postsRouter.get('/:post_id', async function (req, res) { postPNID, replies, canPost, - userContent: self?.content ?? null + userContent: self?.content ?? null, + sort: query.sort }; res.jsxForDirectory({ web: , @@ -201,12 +203,11 @@ postsRouter.delete('/:post_id', async function (req, res) { } res.statusCode = 200; - if (post.parent) { - res.send(`/posts/${post.parent}`); + if (post.parentId) { + res.send(`/posts/${post.parentId}`); } else { res.send('/users/me'); } - await redisRemove(`${post.pid}_user_page_posts`); }); postsRouter.post('/:post_id/new', postLimit, upload.fields([{ name: 'shot', maxCount: 1 }]), async function (req, res) { @@ -228,7 +229,10 @@ postsRouter.get('/:post_id/create', async function (req, res) { return res.sendStatus(404); } - const { data: community } = await req.api.communities.get({ id: parent.community_id }); + if (!parent.community) { + return res.sendStatus(404); + } + const { data: community } = await req.api.communities.get({ id: parent.community.olive_community_id }); if (!community) { return res.sendStatus(404); } @@ -236,8 +240,8 @@ postsRouter.get('/:post_id/create', async function (req, res) { const shotMode = getShotMode(community, auth().paramPackData); const props: NewPostViewProps = { - id: parent.community_id, - pid: parent.pid, + id: parent.community.olive_community_id, + name: parent.author.miiName, url: `/posts/${parent.id}/new`, show: 'post', shotMode, @@ -246,7 +250,8 @@ postsRouter.get('/:post_id/create', async function (req, res) { }; res.jsxForDirectory({ ctr: , - portal: + portal: , + web: }); }); @@ -272,42 +277,79 @@ postsRouter.get('/:post_id/report', async function (req, res) { }); postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { - const { body, auth } = parseReq(req, { + const { body, query } = parseReq(req, { body: z.object({ - reason: z.string(), - message: z.string(), + reason: z.coerce.number(), + message: z.string().trim(), post_id: z.string() + }), + query: z.object({ + api: z.enum(['true', 'false']).default('false') }) }); - const { reason, message, post_id } = body; - const { data: post } = await req.api.posts.get({ post_id }); - if (!reason || !post_id || !post) { + const { data: post } = await req.api.posts.get({ post_id: body.post_id }); + if (!post) { return res.redirect('/404'); } - const duplicate = await database.getDuplicateReports(auth().pid, post_id); - if (duplicate) { + await req.api.posts.report({ + post_id: post.id, + message: body.message, + reasonId: body.reason + }); + + if (query.api === 'true') { + return res.sendStatus(200); + } else { return res.redirect(`/posts/${post.id}`); } +}); - const reportDoc = { - pid: post.pid, - reported_by: auth().pid, - post_id, - reason, - message, - created_at: new Date() - }; +postsRouter.post('/:post_id/edit/spoiler', async function (req, res) { + const { params } = parseReq(req, { + params: z.object({ + post_id: z.string() + }) + }); + + const { data: post } = await req.api.posts.get({ post_id: params.post_id }); + if (!post) { + return res.json({ + success: false + }); + } - const reportObj = new REPORT(reportDoc); - await reportObj.save(); + await req.api.posts.changeSpoiler({ post_id: params.post_id, is_spoiler: true }); - return res.redirect(`/posts/${post.id}`); + res.json({ + success: true + }); +}); + +postsRouter.post('/:post_id/edit/unspoiler', async function (req, res) { + const { params } = parseReq(req, { + params: z.object({ + post_id: z.string() + }) + }); + + const { data: post } = await req.api.posts.get({ post_id: params.post_id }); + if (!post) { + return res.json({ + success: false + }); + } + + await req.api.posts.changeSpoiler({ post_id: params.post_id, is_spoiler: false }); + + res.json({ + success: true + }); }); async function newPost(req: Request, res: Response): Promise { - const { params, body, files, auth, hasAuth } = parseReq(req, { + const { params, body, files, auth } = parseReq(req, { params: z.object({ post_id: z.string().optional() }), @@ -325,183 +367,70 @@ async function newPost(req: Request, res: Response): Promise { }), files: ['shot'] }); - const self = hasAuth() ? auth().self : null; - const rejectReturnUrl = params.post_id ? `/posts/${params.post_id}/create` : `/titles/${body.community_id}/create`; - let parentPost = null; - const postId = await generatePostUID(21); - let { data: community } = await req.api.communities.get({ id: body.community_id }); - if (params.post_id) { - parentPost = await database.getPostByID(params.post_id.toString()); - if (!parentPost) { - res.sendStatus(403); - return; - } else { - const { data: parentPostCommunity } = await req.api.communities.get({ id: parentPost.community_id ?? '' }); - community = parentPostCommunity; - if (parentPost.removed) { - res.sendStatus(400); - return; - } - } - } - if (params.post_id && (body.body === '' && body.painting === '' && body.screenshot === '' && files.shot.length == 0)) { - res.status(422); - return res.redirect('/posts/' + req.params.post_id.toString()); - } - if (!community || !self) { - res.status(403); - logger.error('Incoming post is missing data - rejecting'); - return res.redirect('/titles/show'); - } - - if (!isPostingAllowed(community, self, parentPost)) { - res.status(403); - return res.redirect(`/titles/${community.olive_community_id}/new`); + const rejectReturnUrl = params.post_id ? `/posts/${params.post_id}/create` : `/titles/${body.community_id}/create`; + const postBody: PostCreateBody = { + feelingId: body.feeling_id, + body: body.body.length > 0 ? body.body : undefined, + isSpoiler: body.spoiler, + isAppJumpable: body.is_app_jumpable, + languageId: body.language_id + }; + const paramPack = auth().paramPackData; + if (paramPack) { + postBody.platformId = Number(paramPack.platform_id); + postBody.regionId = Number(paramPack.region_id); + postBody.countryId = Number(paramPack.country_id); } - - let paintings: PaintingUrls | null = null; if (body._post_type === 'painting' && body.painting) { - paintings = await uploadPainting({ - blob: body.painting, - autodetectFormat: false, - isBmp: body.bmp, - pid: auth().pid, - postId - }); - if (paintings === null) { - res.status(422); - res.renderError({ - code: 422, - message: 'Upload failed. Please try again later.' - }); - return; - } + postBody.painting = { + file: body.painting.replace(/\0/g, '').trim(), // Remove nintendo jank + isBmp: body.bmp + }; } - let screenshots = null; - if ((body.screenshot || files.shot.length === 1) && - getShotMode(community, auth().paramPackData) !== 'block') { - screenshots = await uploadScreenshot({ - buffer: files.shot[0]?.buffer, - blob: body.screenshot, - pid: auth().pid, - postId - }); - if (screenshots === null) { - res.status(422); - res.renderError({ - code: 422, - message: 'Upload failed. Please try again later.' - }); - return; + if (body.screenshot || files.shot.length === 1) { + const bufferFile = files.shot[0]?.buffer.toString('base64'); + const base64File = body.screenshot.replace(/\0/g, '').trim(); // Remove nintendo jank + if (!paramPack) { + throw new Error('Must have parampack when uploading screenshot'); } + postBody.screenshot = { + file: bufferFile ?? base64File, + titleId: paramPack.title_id + }; } - let miiFace; - switch (body.feeling_id) { - case 1: - miiFace = 'smile_open_mouth.png'; - break; - case 2: - miiFace = 'wink_left.png'; - break; - case 3: - miiFace = 'surprise_open_mouth.png'; - break; - case 4: - miiFace = 'frustrated.png'; - break; - case 5: - miiFace = 'sorrow.png'; - break; - default: - miiFace = 'normal_face.png'; - break; - } - const postBody = body.body; - if (postBody && getInvalidPostRegex().test(postBody)) { - // TODO - Log this error - res.sendStatus(422); - return; - } - - /* Don't count \r\n as two */ - if (postBody && postBody.replaceAll('\r\n', '\n').length > 280) { - // TODO - Log this error - res.sendStatus(422); - return; + let newPostResult: Post | InternalApiError | null; + if (params.post_id) { + const out = await wrapApi(req.api.posts.reply({ + postCreateBody: postBody, + post_id: params.post_id + })); + newPostResult = out.error ?? out.result ?? null; + } else { + const out = await wrapApi(req.api.communities.createPost({ + postCreateBody: postBody, + id: body.community_id + })); + newPostResult = out.error ?? out.result ?? null; } - const document = { - title_id: community.titleIds[0], - community_id: community.olive_community_id, - screen_name: self.miiName, - body: postBody, - painting: paintings?.blob ?? '', - painting_img: paintings?.img ?? '', - painting_big: paintings?.big ?? '', - screenshot: screenshots?.full ?? '', - screenshot_big: screenshots?.big ?? '', - screenshot_length: screenshots?.fullLength ?? 0, - screenshot_thumb: screenshots?.thumb ?? '', - screenshot_aspect: screenshots?.aspect ?? '', - country_id: auth().paramPackData?.country_id ?? 49, - created_at: new Date(), - feeling_id: body.feeling_id, - id: postId, - is_autopost: 0, - is_spoiler: (body.spoiler) ? 1 : 0, - is_app_jumpable: body.is_app_jumpable, - language_id: body.language_id, - mii: auth().user.mii?.data, - mii_face_url: `${config.cdnDomain}/mii/${auth().pid}/${miiFace}`, - pid: auth().pid, - platform_id: auth().paramPackData?.platform_id ?? 0, - region_id: auth().paramPackData?.region_id ?? 2, - verified: res.locals.moderator, - parent: parentPost ? parentPost.id : null - }; - const maxDuplicatePostAgeMs = 5 * 60 * 1000; - const duplicatePost = await database.getDuplicatePosts(auth().pid, document, maxDuplicatePostAgeMs); - if (duplicatePost && params.post_id) { - return res.redirect('/posts/' + params.post_id.toString()); - } - if ((document.body === '' && document.painting === '' && document.screenshot === '') || duplicatePost) { - return res.redirect('/titles/' + community.olive_community_id + '/new'); + if (newPostResult instanceof InternalApiError) { + if (newPostResult.isCode('automod_prevented')) { + const params = new URLSearchParams(); + params.append('error-text', res.i18n.t('new_post.automod_error')); + return res.redirect(rejectReturnUrl + '?' + params.toString()); + } + throw newPostResult; } - - // Automod - const automodRules = await AutomodRule.find({ enabled: true }); - const automodEval = evaluateAutomodRules(document, automodRules); - const automodResult = await performAutomodAction(document, automodEval); - if (!automodResult.allowPost) { - const params = new URLSearchParams(); - params.append('error-text', res.i18n.t('new_post.automod_error')); - return res.redirect(rejectReturnUrl + '?' + params.toString()); + if (!newPostResult) { + throw new Error('Null newPostResult'); } - // Actual posting - const newPost = new POST(document); - newPost.save(); - if (parentPost) { - parentPost.reply_count = parentPost.reply_count + 1; - parentPost.save(); - } - if (parentPost) { - if (!params.post_id) { - throw new Error('Has parent post but no postId, this is impossible'); - } - res.redirect('/posts/' + params.post_id.toString()); - await redisRemove(`${parentPost.pid}_user_page_posts`); - } else { - res.redirect('/titles/' + community.olive_community_id + '/new'); - await redisRemove(`${auth().pid}_user_page_posts`); + if (newPostResult.parentId) { + res.redirect('/posts/' + newPostResult.parentId.toString()); + return; } -} -async function generatePostUID(length: number): Promise { - let id = Buffer.from(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(length * 2))), 'binary').toString('base64').replace(/[+/]/g, '').substring(0, length); - const inuse = await POST.findOne({ id }); - id = (inuse ? await generatePostUID(length) : id); - return id; + res.redirect('/titles/' + newPostResult.community?.olive_community_id + '/new'); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx index 22a398dd..83ac1a48 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx @@ -1,7 +1,5 @@ import express from 'express'; import { z } from 'zod'; -import { database } from '@/database'; -import { createUser, setName } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebFirstRunView } from '@/services/juxt-web/views/web/firstRunView'; import { PortalFirstRunView } from '@/services/juxt-web/views/portal/firstRunView'; @@ -33,10 +31,6 @@ showRouter.get('/', async function (req, res) { } else { res.redirect('/titles'); } - - if (self) { - setName(self.pid, self.miiName); - } }); showRouter.get('/first', async function (req, res) { @@ -54,10 +48,14 @@ showRouter.post('/newUser', async function (req, res) { notifications: z.boolean() }) }); - const self = hasAuth() ? auth().self : null; - if (!self || !req.new_users || req.directory === 'web') { + const newUsersEnabled = !!req.new_users; // Can this instance onboard new users? + const platformAllowsOnboarding = req.directory !== 'web'; // Web is not allowed to onboard users + const isAuthed = !!self; + + const canDoOnboarding = isAuthed && platformAllowsOnboarding && newUsersEnabled; + if (!canDoOnboarding) { return res.sendStatus(401); } @@ -65,10 +63,9 @@ showRouter.post('/newUser', async function (req, res) { return res.sendStatus(504); // Onboarding already finished } - await createUser(auth().pid, body.experience, body.notifications); - if (await database.getUserSettings(auth().pid) !== null) { - res.sendStatus(200); - } else { - res.sendStatus(504); - } + await req.api.onboarding.submit({ + experienceId: body.experience, + receiveNotifications: body.notifications + }); + res.sendStatus(200); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx index ec74ea79..c90c1d40 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx @@ -1,7 +1,6 @@ import express from 'express'; import { z } from 'zod'; import { config } from '@/config'; -import { POST } from '@/models/post'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { buildPostListLinks, WebPostListView } from '@/services/juxt-web/views/web/postList'; import { CtrPostListView } from '@/services/juxt-web/views/ctr/postList'; @@ -25,11 +24,8 @@ topicsRouter.get('/', async function (req, res) { const offset = query.offset; const userContent = hasAuth() ? auth().self.content : null; - const posts = await POST.find({ - topic_tag: query.topic_tag, - parent: null, - removed: false - }).sort({ created_at: -1 }).skip(offset).limit(query.limit); + const { data: postsPage } = await req.api.posts.list({ topic_tag: query.topic_tag, limit: query.limit, offset }); + const posts = postsPage.items; const link = `/topics?${new URLSearchParams({ topic_tag: query.topic_tag diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx index 08d3b838..b1188910 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx @@ -1,8 +1,6 @@ import express from 'express'; import multer from 'multer'; import { z } from 'zod'; -import { database } from '@/database'; -import { POST } from '@/models/post'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserMissingPage, WebUserPageView } from '@/services/juxt-web/views/web/userPageView'; import { buildPostListLinks, WebPostListView } from '@/services/juxt-web/views/web/postList'; @@ -52,34 +50,15 @@ userPageRouter.get('/notifications.json', async function (req, res) { userPageRouter.get('/downloadUserData.json', async function (req, res) { const { auth } = parseReq(req); - const rawPosts = await POST.find({ pid: auth().pid }); - const userSettings = await database.getUserSettings(auth().pid); - const userContent = await database.getUserContent(auth().pid); + const { data } = await req.api.self.export(); - if (!userContent || !userSettings) { - return res.redirect('/404'); - } - - // Clean non-user data - userSettings.banned_by = null; - const postsJson = rawPosts.map(post => ({ - ...post.toJSON(), - removed_by: null - })); - - const doc = { - user_content: userContent, - user_settings: userSettings, - posts: postsJson - }; res.set('Content-Type', 'text/json'); res.set('Content-Disposition', `attachment; filename="${auth().pid}_user_data.json"`); - res.send(doc); + res.send(JSON.stringify(data, null, 2)); }); userPageRouter.get('/me/settings', async function (req, res) { - const { auth } = parseReq(req); - const userSettings = await database.getUserSettings(auth().pid); + const { data: userSettings } = await req.api.users.me.settings.get(); if (!userSettings) { return res.redirect('/404'); } @@ -103,7 +82,7 @@ userPageRouter.get('/me/:type', async function (req, res) { }); userPageRouter.post('/me/settings', upload.none(), async function (req, res) { - const { body, auth } = parseReq(req, { + const { body } = parseReq(req, { body: z.object({ profile: z.coerce.boolean(), country: z.coerce.boolean(), @@ -112,17 +91,14 @@ userPageRouter.post('/me/settings', upload.none(), async function (req, res) { comment: z.string().optional() }) }); - const userSettings = await database.getUserSettings(auth().pid); - if (!userSettings) { - return res.redirect('/users/me'); - } - userSettings.profile_visibility = body.profile ? 'public' : 'users_only'; - userSettings.country_visibility = body.country; - userSettings.birthday_visibility = body.birthday; - userSettings.game_skill_visibility = body.experience; - userSettings.profile_comment_visibility = !!body.comment; - await userSettings.updateComment(body.comment ?? ''); + await req.api.users.me.settings.update({ + profileVisibility: body.profile ? 'public' : 'users_only', + birthdayVisible: body.birthday, + gameSkillVisible: body.experience, + countryVisible: body.country, + comment: body.comment ? body.comment : null + }); res.redirect('/users/me'); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts index 7eb74412..15ce3569 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts @@ -1,11 +1,9 @@ -import type { InferSchemaType } from 'mongoose'; -import type { PostSchema } from '@/models/post'; import type { ParamPack } from '@/types/common/param-pack'; import type { Community, CommunityShotMode, Post, Self } from '@/api/generated'; -export function isPostingAllowed(community: Community, user: Self, parentPost: InferSchemaType | Post | null): boolean { +export function isPostingAllowed(community: Community, user: Self, parentPost: Post | null): boolean { const isReply = !!parentPost; - const isPublicPostableCommunity = community.type >= 0 && community.type < 2; + const isPublicPostableCommunity = community.type === 0 || community.type === 1; const isOpenCommunity = community.permissions.open; const isCommunityAdmin = community.adminPids.includes(user.pid); @@ -29,8 +27,8 @@ export function isPostingAllowed(community: Community, user: Self, parentPost: I return isReply ? isOpenCommunity : isPublicPostableCommunity; } -export function getShotMode(community: Community, pack: ParamPack | null): CommunityShotMode { - if (pack === null) { +export function getShotMode(community: Community, pack: ParamPack | null | undefined): CommunityShotMode { + if (!pack) { return 'block'; } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx index b37155a1..31727341 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.tsx @@ -1,11 +1,11 @@ import express from 'express'; import { z } from 'zod'; -import { database } from '@/database'; import { passwordLogin, getUserDataFromToken } from '@/util'; import { config } from '@/config'; import { logger } from '@/logger'; import { WebLoginView } from '@/services/juxt-web/views/web/loginView'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; +import { getDiscoveryStatusMessage } from '@/middleware/discovery'; export const loginRouter = express.Router(); const cookieDomain = config.http.cookieDomain; @@ -51,27 +51,15 @@ loginRouter.post('/', async (req, res) => { return res.jsx(); } - const discovery = await database.getEndPoint(config.serverEnvironment); - const discoveryStatus = discovery?.status ?? 5; - - let message = ''; - switch (discoveryStatus) { - case 3: - message = 'Juxt is currently undergoing maintenance. Please try again later.'; - break; - case 4: - message = 'Juxt is currently closed. Thank you for your interest.'; - break; - default: - message = 'Juxt is currently unavailable. Please try again later.'; - break; - } - if (discoveryStatus !== 0) { + const { data: discovery } = await req.api.discovery.get({ environment: config.serverEnvironment }); + const discoveryStatus = discovery.status; + if (discoveryStatus !== 'open') { return res.renderError({ code: 504, - message + message: getDiscoveryStatusMessage(discoveryStatus) }); } + const expiration = login.expiresIn * 60 * 60; res.cookie('access_token', login.accessToken, { domain: cookieDomain, maxAge: expiration }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx index dd866577..9895e45f 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/common/components/RenderContext.tsx @@ -1,5 +1,4 @@ import { createContext, useContext } from 'react'; -import { getCommunityHash, getUserHash } from '@/util'; import type { ReactNode } from 'react'; import type { Response } from 'express'; import type { i18n } from 'i18next'; @@ -11,13 +10,6 @@ export type RenderContextContent = { developer: boolean; pid: number; uaIsConsole?: boolean; // user agent looks like a Nintendo console - usersMap: HashMap; // map of PID -> screen name - - // map of the following: - // olive_community_id -> community name - // title_id -> community name - // -id -> olive_community_id - communityMap: HashMap; }; const InternalRenderContext = createContext(null); @@ -33,8 +25,6 @@ export function useRenderContext(): RenderContextContent { export function buildContext(res: Response): RenderContextContent { const locals = res.locals; return { - usersMap: getUserHash(), - communityMap: getCommunityHash(), cdnUrl: locals.cdnURL, moderator: locals.moderator, developer: locals.developer, diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts b/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts deleted file mode 100644 index d2877171..00000000 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/common/hooks/useCache.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useRenderContext } from '@/services/juxt-web/views/common/components/RenderContext'; - -export type UseCacheValue = { - /* Convert pid into a screen name */ - getUserName: (pid: number) => string | undefined; - - /* Convert title_id or olive_community_id to the community name */ - getCommunityName: (id: string) => string | undefined; -}; - -export function useCache(): UseCacheValue { - const ctx = useRenderContext(); - return { - getCommunityName: id => ctx.communityMap.get(id), - getUserName: pid => ctx.usersMap.get(pid) - }; -} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx index 3528b058..87c012fb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/friendRequestListView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import { CtrPageTitledHeader } from '@/services/juxt-web/views/ctr/components/CtrPageHeader'; @@ -10,21 +9,20 @@ import type { NotificationWrapperViewProps } from '@/services/juxt-web/views/web function CtrFriendRequestItem(props: FriendRequestItemProps): ReactNode { const url = useUrl(); - const cache = useCache(); - const senderId = props.request.sender; + const req = props.request; return (
  • - - + +

    - {cache.getUserName(senderId)} - {props.request.message} + {req.sender.miiName} + {req.message} {' '} - {moment(Number(props.request.sent) * 1000).fromNow()} + {moment(req.sentAt).fromNow()}

    @@ -33,14 +31,10 @@ function CtrFriendRequestItem(props: FriendRequestItemProps): ReactNode { } export function CtrFriendRequestListView(props: FriendRequestListViewProps): ReactNode { - const cache = useCache(); return (
      {props.requests.length === 0 ?
    • : null} {props.requests.map((req, i) => { - if (!cache.getUserName(req.sender)) { - return null; - } return ; })}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx index 5f99d1dd..96c90745 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/newPostView.tsx @@ -4,7 +4,6 @@ import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { CtrPageTitledHeader } from '@/services/juxt-web/views/ctr/components/CtrPageHeader'; import type { ReactNode } from 'react'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; @@ -40,9 +39,8 @@ const empathies = [ export function CtrNewPostView(props: NewPostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const header = props.community ? url.ctrHeader(props.community) : undefined; - const name = props.name ?? cache.getUserName(props.pid ?? 0); + const name = props.name; return (
    + diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx index 53315aec..c23c60aa 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/notificationListView.tsx @@ -1,76 +1,183 @@ import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import { CtrMiiIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrMiiIcon'; import { CtrIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrIcon'; -import { humanFromNow } from '@/util'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; +import { humanDate, humanFromNow } from '@/util'; import { T } from '@/services/juxt-web/views/common/components/T'; import { CtrPageTitledHeader } from '@/services/juxt-web/views/ctr/components/CtrPageHeader'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { NotificationItemProps, NotificationItemTypeProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { FollowNotification, LimitedFromPostingNotification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; -function CtrNotificationItem(props: NotificationItemProps): ReactNode { - const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { - i18nKey = 'notifications.new_follower/two'; - } - if (notif.users.length === 3) { - i18nKey = 'notifications.new_follower/three'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( + <> + +
    +

    + + , + follower_two: + }} + /> + + + {' '} + {humanFromNow(props.data.updatedAt)} + +

    +
    + + ); +} + +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } - if (notif.users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + } + + return ( + <> + + + + ); +} + +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; } + } - return ( - <> - -
    -

    - + return ( + <> + +

    + +

    + , - follower_two: + /> + {' '} + - + {' '} - {humanFromNow(notif.updatedAt)} + {humanFromNow(props.data.updatedAt)}

    -
    - - ); + +
    + + ); +} + +function SystemNotificationView(props: NotificationItemTypeProps): ReactNode { + return ( + <> + + + + ); +} + +function CtrNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; } - if (notif.type === 'notice') { - return ( - <> - - - - ); + if (notif.type === 'postDeleted') { + return ; + } + + if (notif.type === 'limitedFromPosting') { + return ; + } + + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx index f9c47ccd..2d88a20a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/post.tsx @@ -1,7 +1,6 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { CtrMiiIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrMiiIcon'; import { CtrButton } from '@/services/juxt-web/views/ctr/components/ui/CtrButton'; @@ -13,18 +12,18 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { const url = useUrl(); const post = props.post; if (!post.screenshot) { - return <>; + return null; } - if (post.screenshot_aspect && post.screenshot_thumb) { + if (post.screenshot.aspectRatio && post.screenshot.imageUrlThumbnail) { // modern type return ( ); } else { @@ -32,7 +31,7 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { return ( ); } @@ -41,10 +40,9 @@ function CtrPostScreenshot(props: PostScreenshotProps): ReactNode { export function CtrPostView(props: PostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const post = props.post; - const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; + const hasYeahed = post.yeahsBy.some(v => v.pid === user.pid); // TODO implement moderator removed post logic return ( @@ -52,48 +50,51 @@ export function CtrPostView(props: PostViewProps): ReactNode { id={`post-${post.id}`} className={cx('post', { reply: props.isReply, - spoiler: post.is_spoiler + spoiler: post.isSpoiler })} > - +
    - {post.screen_name} + {post.author.miiName} {' '} {'- '} - {moment(post.created_at).fromNow()} + {moment(post.createdAt).fromNow()} - { post.topic_tag + { post.topicTag ? ( - - - - {post.topic_tag} - - + <> +
    + + + + {post.topicTag} + + + ) : null }
    { !props.isReply ? ( - + - + - {cache.getCommunityName(post.community_id ?? '')} + {post.community?.name} ) : null} - { post.is_spoiler + { post.isSpoiler ? (
    @@ -102,15 +103,15 @@ export function CtrPostView(props: PostViewProps): ReactNode { : null }
    - {post.body !== '' + {post.body ? (

    {post.body}

    ) : null} - {post.painting !== '' + {post.painting ? ( - + ) : null} {/* TODO add post.url back */} @@ -118,20 +119,20 @@ export function CtrPostView(props: PostViewProps): ReactNode {
    - { props.isMainPost && post.yeahs.length > 0 + { props.isMainPost && post.yeahsBy.length > 0 ? ( <>
    { post.yeahs.length } + count: { post.yeahsBy.length } }} />
    - {post.yeahs.slice(0, 10).map(pid => ( + {post.yeahsBy.slice(0, 10).map(({ pid }) => ( ))}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx index 6d587cb7..b41391c5 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/postPageView.tsx @@ -21,7 +21,7 @@ export function CtrPostPageView(props: PostPageViewProps): ReactNode { header={header} data-toolbar-mode="normal" > - {post.screen_name} + {post.author.miiName} {props.canPost @@ -35,7 +35,7 @@ export function CtrPostPageView(props: PostPageViewProps): ReactNode { ) : null } - {post.pid === user.pid + {post.author.pid === user.pid ? (
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx index 53f2f8d0..5a098015 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userPageFollowingView.tsx @@ -19,9 +19,9 @@ export function CtrUserPageFollowingView(props: UserPageFollowingViewProps): Rea
  • ))} {props.communities.map(community => ( -
  • - - +
  • + +
    {community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userSettingsView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userSettingsView.tsx index cb613ad2..e26b25e6 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userSettingsView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/userSettingsView.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react'; import type { UserSettingsViewProps } from '@/services/juxt-web/views/web/userSettingsView'; export function CtrUserSettingsView(props: UserSettingsViewProps): ReactNode { + const settings = props.userSettings; return ( @@ -22,28 +23,28 @@ export function CtrUserSettingsView(props: UserSettingsViewProps): ReactNode {
  • diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx index a93a1f36..d7ce4199 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/firstRunView.tsx @@ -85,7 +85,7 @@ export function PortalFirstRunView(_props: FirstRunViewProps): ReactNode {

    - + diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx index 2049df1a..3029afd8 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/friendRequestListView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/components/PortalNavBar'; @@ -10,21 +9,20 @@ import type { NotificationWrapperViewProps } from '@/services/juxt-web/views/web function PortalFriendRequestItem(props: FriendRequestItemProps): ReactNode { const url = useUrl(); - const cache = useCache(); - const senderId = props.request.sender; + const req = props.request; return (
  • - - + +

    - {cache.getUserName(senderId)} - {props.request.message} + {req.sender.miiName} + {req.message} {' '} - {moment(Number(props.request.sent) * 1000).fromNow()} + {moment(req.sentAt).fromNow()}

    @@ -33,14 +31,10 @@ function PortalFriendRequestItem(props: FriendRequestItemProps): ReactNode { } export function PortalFriendRequestListView(props: FriendRequestListViewProps): ReactNode { - const cache = useCache(); return (
      {props.requests.length === 0 ?
    • : null} {props.requests.map((req, i) => { - if (!cache.getUserName(req.sender)) { - return null; - } return ; })}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx index 71da64f7..b8c0f8bb 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/newPostView.tsx @@ -3,7 +3,6 @@ import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/components/PortalNavBar'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import type { ReactNode } from 'react'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; @@ -50,9 +49,8 @@ const empathies = [ export function PortalNewPostView(props: NewPostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); - const name = props.name ?? cache.getUserName(props.pid ?? 0); + const name = props.name; return (
    @@ -138,10 +136,8 @@ export function PortalNewPostView(props: NewPostViewProps): ReactNode { } export function PortalNewPostPage(props: NewPostViewProps): ReactNode { - const cache = useCache(); - const name = props.name ?? cache.getUserName(props.pid ?? 0); return ( - + diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx index 6ee4d5da..96b8f638 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/notificationListView.tsx @@ -1,76 +1,179 @@ import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/components/PortalNavBar'; -import { humanFromNow } from '@/util'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; +import { humanDate, humanFromNow } from '@/util'; import { T } from '@/services/juxt-web/views/common/components/T'; import { PortalMiiIcon } from '@/services/juxt-web/views/portal/components/ui/PortalMiiIcon'; import { PortalIcon } from '@/services/juxt-web/views/portal/components/ui/PortalIcon'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { NotificationItemProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { NotificationItemProps, NotificationItemTypeProps, NotificationListViewProps, NotificationWrapperViewProps } from '@/services/juxt-web/views/web/notificationListView'; +import type { FollowNotification, LimitedFromPostingNotification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; -function PortalNotificationItem(props: NotificationItemProps): ReactNode { - const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? null}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { - i18nKey = 'notifications.new_follower/two'; - } - if (notif.users.length === 3) { - i18nKey = 'notifications.new_follower/three'; - } - if (notif.users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( + <> + +
    +

    + + , + follower_two: + }} + /> + + + {' '} + {humanFromNow(props.data.updatedAt)} + +

    +
    + + ); +} + +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } + } - return ( - <> - - + + ); +} + +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } } - if (notif.type === 'notice') { - return ( - <> - - + + ); +} + +function SystemNotificationView(props: NotificationItemTypeProps): ReactNode { + return ( + <> + + + + ); +} + +function PortalNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; + } + + if (notif.type === 'postDeleted') { + return ; + } + + if (notif.type === 'limitedFromPosting') { + return ; + } + + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx index 58ed3e7b..487f2243 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/post.tsx @@ -1,7 +1,6 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { PortalUIIcon } from '@/services/juxt-web/views/portal/components/ui/PortalUIIcon'; import { T } from '@/services/juxt-web/views/common/components/T'; @@ -13,18 +12,18 @@ function PortalPostScreenshot(props: PostScreenshotProps): ReactNode { const post = props.post; if (!post.screenshot) { - return <>; + return null; } - if (post.screenshot_aspect) { + if (post.screenshot.aspectRatio) { // modern type return ( ); } else { @@ -32,7 +31,7 @@ function PortalPostScreenshot(props: PostScreenshotProps): ReactNode { return ( ); } @@ -40,21 +39,20 @@ function PortalPostScreenshot(props: PostScreenshotProps): ReactNode { export function PortalPostView(props: PostViewProps): ReactNode { const url = useUrl(); - const cache = useCache(); const user = useUser(); const post = props.post; - const hasYeahed = post.yeahs && post.yeahs.indexOf(user.pid) !== -1; + const hasYeahed = post.yeahsBy.some(v => v.pid === user.pid); const isModerator = user.perms.moderator; // TODO implement moderator removed post logic const content = ( <> - - + +
    - {post.screen_name} + {post.author.miiName} {' '} {'- '} - {moment(post.created_at).fromNow()} + {moment(post.createdAt).fromNow()} - {post.topic_tag + {post.topicTag ? ( - + {/* TODO this has been modified due to inbalanced tags */} - {post.topic_tag} + {post.topicTag} ) : null } @@ -85,16 +83,16 @@ export function PortalPostView(props: PostViewProps): ReactNode { { !props.isReply ? ( - + - + - {cache.getCommunityName(post.community_id ?? '')} + {post.community?.name} ) : null} - { post.is_spoiler + { post.isSpoiler ? (
    @@ -106,9 +104,9 @@ export function PortalPostView(props: PostViewProps): ReactNode { className="post-content" data-href={!props.isReply ? `/posts/${post.id}` : undefined} > - {post.body !== '' ?

    {post.body}

    : null} + {post.body ?

    {post.body}

    : null} - {post.painting !== '' ? : null} + {post.painting ? : null} {/* TODO add post.url back */}
    @@ -123,14 +121,14 @@ export function PortalPostView(props: PostViewProps): ReactNode { {' '} - { props.isReply && post.pid !== user.pid && !isModerator + { props.isReply && post.author.pid !== user.pid && !isModerator ? (
    ) : null} - { props.isReply && (post.pid === user.pid || isModerator) + { props.isReply && (post.author.pid === user.pid || isModerator) ? (
    @@ -141,10 +139,10 @@ export function PortalPostView(props: PostViewProps): ReactNode { ? ( <> {' '} - {post.empathy_count} + {post.stats.empathyCount} { !props.isReply ? ( - {post.reply_count} + {post.stats.replyCount} ) : null} {' '} @@ -155,22 +153,22 @@ export function PortalPostView(props: PostViewProps): ReactNode {
    - { props.isMainPost && post.yeahs.length > 0 + { props.isMainPost && post.yeahsBy.length > 0 ? ( <>
    {post.empathy_count} + count: {post.stats.empathyCount} }} />
    @@ -185,10 +183,10 @@ export function PortalPostView(props: PostViewProps): ReactNode { id={`post-${post.id}`} className={cx('post', { reply: props.isReply, - spoiler: post.is_spoiler + spoiler: post.isSpoiler })} > - {post.removed + {post.moderation?.removed ? (

    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx index e93c4281..e9e88496 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/postPageView.tsx @@ -9,7 +9,8 @@ import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageVi export function PortalPostPageView(props: PostPageViewProps): ReactNode { const user = useUser(); const { post } = props; - const pageTitle = !post.removed ? post.screen_name : 'Removed Post'; + const removedData = post.moderation?.removed; + const pageTitle = !removedData ? post.author.miiName : 'Removed Post'; return ( @@ -17,7 +18,7 @@ export function PortalPostPageView(props: PostPageViewProps): ReactNode {
  • ))} {props.communities.map(community => ( -
  • - - +
  • + +
    {community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userSettingsView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userSettingsView.tsx index 39f552f7..44997fd0 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userSettingsView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userSettingsView.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react'; import type { UserSettingsViewProps } from '@/services/juxt-web/views/web/userSettingsView'; export function PortalUserSettingsView(props: UserSettingsViewProps): ReactNode { + const settings = props.userSettings; return ( @@ -17,28 +18,28 @@ export function PortalUserSettingsView(props: UserSettingsViewProps): ReactNode
  • diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx index 1bb567e1..6c225641 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/automodLogListView.tsx @@ -3,7 +3,6 @@ import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebAdminCenterItems, WebModerationTabs } from '@/services/juxt-web/views/web/admin/admin'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { WebMiiIcon } from '@/services/juxt-web/views/web/components/ui/WebMiiIcon'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { humanDate, humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { AutomodLog } from '@/api/generated'; @@ -56,20 +55,18 @@ function LogBodyWithMatches({ log }: AutomodLogItemViewProps): ReactNode { } export function AutomodLogItem({ log }: AutomodLogItemViewProps): ReactNode { - const cache = useCache(); - return (
  • - + {`Post ${log.action} by `} - - {cache.getUserName(log.postAuthor)} + + {log.postAuthor?.miiName} {' - '} - {log.postAuthor} + {log.postAuthor?.pid} {' - '} {humanFromNow(log.createdAt)} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx index 3de542f4..f82ec25e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/manageCommunityView.tsx @@ -40,7 +40,7 @@ export function WebManageCommunityView(props: ManageCommunityViewProps): ReactNo <> diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx index a47f4366..025f69ef 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserPostView.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import { WebPostView } from '@/services/juxt-web/views/web/post'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import type { ReactNode } from 'react'; import type { Post } from '@/api/generated'; @@ -10,7 +9,6 @@ export type ModerateUserRemovedPostsViewProps = { }; export function ModerateUserRemovedPostView(props: ModerateUserRemovedPostsViewProps): ReactNode { - const cache = useCache(); const url = useUrl(); return ( @@ -22,36 +20,43 @@ export function ModerateUserRemovedPostView(props: ModerateUserRemovedPostsViewP
    ); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx index f5493132..c5b2871e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserReportsView.tsx @@ -1,5 +1,4 @@ import moment from 'moment'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { WebPostView } from '@/services/juxt-web/views/web/post'; import { AutomodLogItem } from '@/services/juxt-web/views/web/admin/automodLogListView'; @@ -21,7 +20,6 @@ type ModerateUserReportProps = { function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { const { reporter, resolved } = props.report; const createdAt = new Date(props.report.createdAt); - const cache = useCache(); const url = useUrl(); return ( @@ -37,7 +35,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Reported By: {' '} - {cache.getUserName(reporter.pid)} + {reporter.user?.miiName ?? 'Unknown'} {' '} {moment(createdAt).fromNow()} @@ -65,7 +63,7 @@ function ModerateUserReportView(props: ModerateUserReportProps): ReactNode { Resolved By: {' '} - {resolved.pid ? cache.getUserName(resolved.pid) : 'Nobody'} + {resolved.pid ? resolved.user?.miiName : 'Nobody'} {' '} {moment(resolved.resolvedAt).fromNow()} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserView.tsx index c23d3a9c..2bc553b9 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/moderate-user/moderateUserView.tsx @@ -5,6 +5,7 @@ import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebUserPageMeta, WebUserTier } from '@/services/juxt-web/views/web/userPageView'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalView'; import type { ReactNode } from 'react'; import type { AdminUserProfile } from '@/api/generated'; @@ -109,6 +110,7 @@ export function WebModerateUserView(props: ModerateUserViewProps): ReactNode { {props.children} + ); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx index 1ffb3dae..efa8ef63 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx @@ -2,7 +2,6 @@ import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebModerationTabs } from '@/services/juxt-web/views/web/admin/admin'; import { WebPostView } from '@/services/juxt-web/views/web/post'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { humanDate, humanFromNow } from '@/util'; import { WebMiiIcon } from '@/services/juxt-web/views/web/components/ui/WebMiiIcon'; import type { ReactNode } from 'react'; @@ -21,7 +20,6 @@ export type ReportProps = { }; function Report(props: ReportProps): ReactNode { - const cache = useCache(); const createdAt = new Date(props.report.createdAt); const reporter = props.report.reporter; @@ -34,7 +32,7 @@ function Report(props: ReportProps): ReactNode { - {`Reported by ${cache.getUserName(reporter.pid)}`} + {`Reported by ${reporter.user.miiName}`} {' - '} {reporter.pid} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye.svg b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye.svg new file mode 100644 index 00000000..6b47d096 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye_slash.svg b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye_slash.svg new file mode 100644 index 00000000..4aed57f3 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/eye_slash.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg new file mode 100644 index 00000000..721e2f91 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/assets/up_down.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx index e02ce2b1..e030fe17 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/communityView.tsx @@ -111,6 +111,13 @@ export function WebCommunityView(props: CommunityViewProps): ReactNode { ) : null} + {user.perms.moderator && props.canPost + ? ( + + Create post + + ) + : null} ); } + +export function WebNewPostPage(props: NewPostViewProps): ReactNode { + const user = useUser(); + + let content = ; + if (!user.perms.moderator) { + content = ( +
    +

    Posting is disabled on web

    +
    + ); + } + + return ( + +

    + +

    + + {content} + +
    + ); +} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx index 8cf00c93..bccdde43 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/notificationListView.tsx @@ -1,13 +1,12 @@ -import moment from 'moment'; import { WebRoot, WebWrapper } from '@/services/juxt-web/views/web/root'; import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalView'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { humanDate, humanFromNow } from '@/util'; import type { ReactNode } from 'react'; import type { TranslationKey } from '@/services/juxt-web/views/common/components/T'; -import type { Notification } from '@/api/generated'; +import type { FollowNotification, LimitedFromPostingNotification, Notification, PostDeletedNotification, ShallowUser, SystemNotification } from '@/api/generated'; export type NotificationWrapperViewProps = { children?: ReactNode; @@ -21,71 +20,179 @@ export type NotificationItemProps = { notification: Notification; }; -function WebNotificationItem(props: NotificationItemProps): ReactNode { +export type NotificationItemTypeProps = { + data: Notification; + notif: T; +}; + +function FollowNotificationView(props: NotificationItemTypeProps): ReactNode { const url = useUrl(); - const cache = useCache(); - const notif = props.notification; - if (notif.type === 'follow') { - const NickName = ({ userId }: { userId: string | number | null | undefined }): ReactNode => {userId ? cache.getUserName(Number(userId)) : null}; + const NickName = ({ user }: { user: ShallowUser | null | undefined }): ReactNode => {user?.miiName ?? 'Nobody'}; + const users = [...props.notif.content.users].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestUser = users[0]; - let i18nKey: TranslationKey = 'notifications.new_follower/one'; - if (notif.users.length === 2) { - i18nKey = 'notifications.new_follower/two'; - } - if (notif.users.length === 3) { - i18nKey = 'notifications.new_follower/three'; - } - if (notif.users.length > 3) { - i18nKey = 'notifications.new_follower/multiple'; + let i18nKey: TranslationKey = 'notifications.new_follower.message/one'; + if (users.length === 2) { + i18nKey = 'notifications.new_follower.message/two'; + } + if (users.length === 3) { + i18nKey = 'notifications.new_follower.message/three'; + } + if (users.length > 3) { + i18nKey = 'notifications.new_follower.message/multiple'; + } + + return ( +
    + ); +} + +function PostDeletedNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.post_deleted.post_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.post_removed_for_reason'; + } + if (props.notif.content.postType === 'comment') { + i18nKey = 'notifications.post_deleted.comment_removed'; + if (props.notif.content.reason) { + i18nKey = 'notifications.post_deleted.comment_removed_for_reason'; } + } - return ( - + ); +} + +function LimitedFromPostingNotificationView(props: NotificationItemTypeProps): ReactNode { + let i18nKey: TranslationKey = 'notifications.limited_from_posting.message'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.message_with_reason'; + } + if (props.notif.content.until) { + i18nKey = 'notifications.limited_from_posting.temporary'; + if (props.notif.content.reason) { + i18nKey = 'notifications.limited_from_posting.temporary_with_reason'; + } } - if (notif.type === 'notice') { - return ( - + ); +} + +function WebNotificationItem(props: NotificationItemProps): ReactNode { + const notif = props.notification.notif; + if (notif.type === 'follow') { + return ; + } + + if (notif.type === 'postDeleted') { + return ; + } + + if (notif.type === 'limitedFromPosting') { + return ; + } + + if (notif.type === 'system') { + return ; } return
    Invalid notification type!
    ; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index b5872cfd..bfed562e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -1,32 +1,29 @@ import cx from 'classnames'; import moment from 'moment'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import { useCache } from '@/services/juxt-web/views/common/hooks/useCache'; import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; import { WebUIIcon } from '@/services/juxt-web/views/web/components/ui/WebUIIcon'; import { T } from '@/services/juxt-web/views/common/components/T'; -import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { PostSchema } from '@/models/post'; import type { Post, SelfContent } from '@/api/generated'; export type PostScreenshotProps = { - post: InferSchemaType | Post; + post: Post; }; export function WebPostScreenshot(props: PostScreenshotProps): ReactNode { const url = useUrl(); const post = props.post; if (!post.screenshot) { - return <>; + return null; } - return ; + return ; } export type PostViewProps = { userContent?: SelfContent | null; - post: InferSchemaType | Post; + post: Post; isReply?: boolean; isMainPost?: boolean; }; @@ -34,15 +31,13 @@ export type PostViewProps = { export function WebPostView(props: PostViewProps): ReactNode { const url = useUrl(); const user = useUser(); - const cache = useCache(); const post = props.post; const isModerator = user.perms.moderator; - const canAccessContent = !post.removed || isModerator; - const yeahed = !!props.userContent && !!post.yeahs && post.yeahs.includes(user.pid); + const yeahed = post.yeahsBy.some(v => v.pid === user.pid); let removedPostPart = null; - if (post.removed) { + if (post.moderation?.removed) { removedPostPart = (

    @@ -53,34 +48,34 @@ export function WebPostView(props: PostViewProps): ReactNode { const contentPart = ( <> - { post.is_spoiler + { post.isSpoiler ? (
    @@ -115,7 +110,7 @@ export function WebPostView(props: PostViewProps): ReactNode { aria-pressed={yeahed} > -

    {post.empathy_count}

    +

    {post.stats.empathyCount}

    {/* Reply "button" */} @@ -125,14 +120,14 @@ export function WebPostView(props: PostViewProps): ReactNode { role="button" > -

    {post.reply_count}

    +

    {post.stats.replyCount}

    {/* Hamburger menu */}
    @@ -162,9 +175,9 @@ export function WebPostView(props: PostViewProps): ReactNode { ); return ( -
    +
    {removedPostPart} - {canAccessContent ? contentPart : null} + {contentPart}
    ); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx index 4adcd636..1ef1b338 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx @@ -1,14 +1,12 @@ import { WebPostView } from '@/services/juxt-web/views/web/post'; import { T } from '@/services/juxt-web/views/common/components/T'; import { buildUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; -import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { PostSchema } from '@/models/post'; import type { Post, SelfContent } from '@/api/generated'; export type PostListViewProps = { userContent: SelfContent | null; - posts: InferSchemaType[] | Post[]; + posts: Post[]; nextLink: string; prevPageLink: string | null; nextPageLink: string; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx index 19ecbee5..65a3815e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx @@ -4,6 +4,8 @@ import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalVie import { WebPostView } from '@/services/juxt-web/views/web/post'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { useUser } from '@/services/juxt-web/views/common/hooks/useUser'; +import { WebUIIcon } from '@/services/juxt-web/views/web/components/ui/WebUIIcon'; import type { ReactNode } from 'react'; import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { Community, Post, SelfContent } from '@/api/generated'; @@ -15,27 +17,28 @@ export type PostPageViewProps = { community: Community; replies: Post[]; canPost: boolean; + sort: 'newest-first' | 'oldest-first'; }; function PostHead(props: PostPageViewProps): ReactNode { const url = useUrl(); const post = props.post; - const pageTitle = T.str('post.title', { username: post.screen_name }); + const pageTitle = T.str('post.title', { username: post.author.miiName }); - if (post.removed) { + if (post.moderation?.removed) { return ( {pageTitle} ); } - const title = `${post.screen_name} (@${props.postPNID.username}) - ${props.community.name}`; + const title = `${post.author.miiName} (@${props.postPNID.username}) - ${props.community.name}`; const description = post.body + '\n\n' + - `${post.reply_count} 🗨️ ${post.empathy_count} ❤️`; + `${post.stats.replyCount} 🗨️ ${post.stats.empathyCount} ❤️`; let image: string | null = null; if (post.screenshot) { - image = url.cdn(post.screenshot); + image = url.cdn(post.screenshot.imageUrl); } else if (post.painting) { - image = url.cdn(`/paintings/${post.pid}/${post.id}.png`); + image = url.cdn(`/paintings/${post.author.pid}/${post.id}.png`); } return ( @@ -67,6 +70,8 @@ function PostHead(props: PostPageViewProps): ReactNode { } export function WebPostPageView(props: PostPageViewProps): ReactNode { + const user = useUser(); + return ( }>

    @@ -75,6 +80,30 @@ export function WebPostPageView(props: PostPageViewProps): ReactNode {
    +
    +

    + {user.perms.moderator + ? ( + + + + + ) + : null } + {props.sort === 'newest-first' + ? ( + + + + + ) + : ( + + + + + )} +
    {props.replies.map(replyPost => (
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx index e737cbe0..891c50b3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/reportModalView.tsx @@ -18,16 +18,19 @@ export function WebReportModalView(): ReactNode {

    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx index 2392db6c..58c2ede2 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/subCommunityView.tsx @@ -13,7 +13,7 @@ export function WebSubCommunityView(props: SubCommunityViewProps): ReactNode {
    - +
  • ))} {props.communities.map(community => ( -
  • +
  • diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userSettingsView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userSettingsView.tsx index 9a41a57f..01348458 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userSettingsView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userSettingsView.tsx @@ -1,7 +1,7 @@ -import type { HydratedSettingsDocument } from '@/models/settings'; +import type { UserSettings } from '@/api/generated'; // Web doesn't render settings, so this file only holds the types. export type UserSettingsViewProps = { - userSettings: HydratedSettingsDocument; + userSettings: UserSettings; }; diff --git a/apps/juxtaposition-ui/src/util.ts b/apps/juxtaposition-ui/src/util.ts index 17e784b4..b73f9b23 100644 --- a/apps/juxtaposition-ui/src/util.ts +++ b/apps/juxtaposition-ui/src/util.ts @@ -3,43 +3,24 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createChannel, createClient, Metadata } from 'nice-grpc'; import { AccountDefinition } from '@pretendonetwork/grpc/account/account_service'; -import { FriendsDefinition } from '@pretendonetwork/grpc/friends/friends_service'; import { APIDefinition } from '@pretendonetwork/grpc/api/api_service'; -import HashMap from 'hashmap'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import crc32 from 'crc/crc32'; import { DateTime } from 'luxon'; import { z } from 'zod'; import { rateLimit } from 'express-rate-limit'; -import { database } from '@/database'; -import { COMMUNITY } from '@/models/communities'; import { logger } from '@/logger'; -import { CONTENT } from '@/models/content'; -import { SETTINGS } from '@/models/settings'; import { config } from '@/config'; import { SystemType } from '@/types/common/system-types'; import { TokenType } from '@/types/common/token-types'; -import { AutomodLog } from '@/models/automodLog'; import type { Options as RatelimitOptions } from 'express-rate-limit'; import type { ZodType } from 'zod'; -import type { ObjectCannedACL } from '@aws-sdk/client-s3'; -import type { InferSchemaType } from 'mongoose'; import type { GetUserDataResponse as AccountGetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { GetUserDataResponse as ApiGetUserDataResponse } from '@pretendonetwork/grpc/api/get_user_data_rpc'; -import type { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; import type { LoginResponse } from '@pretendonetwork/grpc/api/login_rpc'; -import type { GetUserFriendPIDsResponse } from '@pretendonetwork/grpc/friends/get_user_friend_pids_rpc'; import type { RequestHandler } from 'express'; import type { Config } from '@/config'; import type { ServiceToken } from '@/types/common/service-token'; import type { ParamPack } from '@/types/common/param-pack'; -import type { CommunitySchema } from '@/models/communities'; -import type { AdminCommunity } from '@/api/generated'; -import type { AutomodAction } from '@/models/automodLog'; -import type { HydratedAutomodRuleDocument } from '@/models/automodRules'; - -const gRPCFriendsChannel = createChannel(`${config.grpc.friends.host}:${config.grpc.friends.port}`); -const gRPCFriendsClient = createClient(FriendsDefinition, gRPCFriendsChannel); const gRPCAccountChannel = createChannel(`${config.grpc.account.host}:${config.grpc.account.port}`); const gRPCAccountClient = createClient(AccountDefinition, gRPCAccountChannel); @@ -47,120 +28,6 @@ const gRPCAccountClient = createClient(AccountDefinition, gRPCAccountChannel); const gRPCApiChannel = createChannel(`${config.grpc.account.host}:${config.grpc.account.port}`); const gRPCApiClient = createClient(APIDefinition, gRPCApiChannel); -const s3 = new S3Client({ - endpoint: config.s3.endpoint, - forcePathStyle: true, - region: config.s3.region, - credentials: { - accessKeyId: config.s3.key, - secretAccessKey: config.s3.secret - } -}); - -const communityMap = new HashMap(); -const userMap = new HashMap(); - -/** - * Map from {olive_community_id, title_id} to name - * and also title_id + '-id' to olive_community_id - */ -export function getCommunityHash(): HashMap { - return communityMap; -} -/** - * Map from pid to screen_name (transformed) - */ -export function getUserHash(): HashMap { - return userMap; -} - -refreshCache(); - -function refreshCache(): void { - database.connect().then(async () => { - for await (const community of COMMUNITY.find()) { - updateCommunityHash(community); - } - logger.success('Created community index'); - - const users = await SETTINGS.find({}); - - for (const user of users) { - if (user.pid === undefined || user.screen_name === undefined) { - continue; - } - - setName(user.pid, user.screen_name); - } - logger.success('Created user index of ' + users.length + ' users'); - }).catch((error) => { - logger.error(error); - }); -} - -/** - * Updates a user's name in the user map. - */ -export function setName(pid: number, name: string): void { - userMap.set(pid, name.replace(/[\u{0080}-\u{FFFF}]/gu, '').replace(/\u202e/g, '')); -} - -/** - * Updates a community's info in the map. - */ -export function updateCommunityHash(community: Pick, 'title_id' | 'olive_community_id' | 'name'>): void { - if (community.title_id === undefined || - community.olive_community_id === undefined || - community.name === undefined - ) { - return; - } - - for (const title_id of community.title_id) { - communityMap.set(title_id, community.name); - communityMap.set(title_id + '-id', community.olive_community_id); - } - communityMap.set(community.olive_community_id, community.name); -} -export function updateCommunityHashForAdminCommunity(community: AdminCommunity): void { - updateCommunityHash({ - name: community.name, - olive_community_id: community.olive_community_id, - title_id: community.titleIds - }); -} - -// TODO - This doesn't belong here, just hacking it in. Gonna redo this whole server anyway so fuck it -export function getInvalidPostRegex(): RegExp { - return /[^\p{L}\p{P}\d\n\r$^¨←→↑↓√|¦⇒⇔¤¢€£¥™©®+×÷=±∞`΄΅˘˙¸˛~˜°¹²³♭♪¬¯¼½¾♡♥●◆■▲▼☆★♀♂<> ]/gu; -} - -export async function createUser(pid: number, experience: number, notifications: boolean): Promise { - const pnid = await getUserAccountData(pid); - if (!pnid) { - return; - } - - const name = pnid.mii?.name ?? 'Default'; - - const newSettings = { - pid: pid, - screen_name: name, - game_skill: experience, - receive_notifications: notifications - }; - const newContent = { - pid: pid - }; - const newSettingsObj = new SETTINGS(newSettings); - await newSettingsObj.save(); - - const newContentObj = new CONTENT(newContent); - await newContentObj.save(); - - setName(pid, name); -} - export function decodeParamPack(paramPack: string): ParamPack { const values = Buffer.from(paramPack, 'base64').toString().split('\\').filter(v => v.length > 0).values(); const entries: Record = {}; @@ -274,53 +141,23 @@ export function getReasonMap(): string[] { 'Piracy', 'Inappropriate Behavior in Game', 'Other', - 'Missing Images; Reach out to Jemma with post link to fix' + 'Missing Images; Reach out to Jemma with post link to fix', + 'Mean/Rude/Hateful (Rule 1)', + 'Inappropriate/NSFW (Rule 2)', + 'Spam/Self-Promotion (Rule 3)', + 'Off-Topic (Rule 4)', + 'Piracy (Rule 5)', + 'API Abuse/Exploiting Bugs (Rule 8)', + 'Drama (Rule 9)', + 'Cheating Online (Rule 10)', + 'Spoiler (Rule 11)', + 'Personal Information (Rule 12)', + 'Politics (Rule 13)', + 'Misinformation/Bad Advice (Rule 14)', + 'Impersonation (Rule 15)' ]; } -export async function uploadCDNAsset(key: string, data: Buffer, acl: ObjectCannedACL): Promise { - const awsPutParams = new PutObjectCommand({ - Body: data, - Key: key, - Bucket: config.s3.bucket, - ACL: acl - }); - try { - await s3.send(awsPutParams); - return true; - } catch (e) { - logger.error(e, 'Could not upload to CDN'); - return false; - } -} - -export async function getUserFriendPIDs(pid: number): Promise { - const response = await gRPCFriendsClient.getUserFriendPIDs({ - pid: pid - }, { - metadata: Metadata({ - 'X-API-Key': config.grpc.friends.apiKey - }) - }).catch((err): GetUserFriendPIDsResponse => { - logger.error(err, `Couldn't fetch friends for user ${pid}`); - return { pids: [] }; - }); - - return response.pids; -} - -export async function getUserFriendRequestsIncoming(pid: number): Promise { - const response = await gRPCFriendsClient.getUserFriendRequestsIncoming({ - pid: pid - }, { - metadata: Metadata({ - 'X-API-Key': config.grpc.friends.apiKey - }) - }); - - return response.friendRequests; -} - export function getUserAccountData(pid: number): Promise { return gRPCAccountClient.getUserData({ pid: pid @@ -363,98 +200,6 @@ export async function passwordLogin(username: string, password: string): Promise // }); // } -export type AutomodRuleEvaluationMatch = { - start: number; - end: number; -}; -export type AutomodRuleEvaluation = { - violatedRule: HydratedAutomodRuleDocument; - matches: AutomodRuleEvaluationMatch[]; - action: AutomodAction; -} | null; - -export function evaluateAutomodRules(post: any, rules: HydratedAutomodRuleDocument[]): AutomodRuleEvaluation { - const blockRules = rules.filter(v => v.mode === 'block'); - const nonBlockRules = rules.filter(v => v.mode !== 'block'); - const orderedRules = [...blockRules, ...nonBlockRules]; - - for (const rule of orderedRules) { - let hasMatched = false; - const matches: AutomodRuleEvaluationMatch[] = []; - if (rule.type === 'keyword') { - const bodyNormalized: string = (post.body ?? '').toLowerCase(); - const keywordsToCheck = rule.keyword_settings?.keywords ?? []; - keywordsToCheck.forEach((keywordUpper) => { - const keyword = keywordUpper.toLowerCase(); - const index = bodyNormalized.indexOf(keyword); - if (index < 0) { - return; - } - - hasMatched = true; - matches.push({ - start: index, - end: index + keyword.length - }); - }); - } - - if (hasMatched) { - return { - action: rule.mode === 'block' ? 'blocked' : 'logged', - matches, - violatedRule: rule - }; - } - } - - return null; // No rules apply to this post -} - -export async function performAutomodAction(post: any, evaluation: AutomodRuleEvaluation): Promise<{ allowPost: boolean }> { - if (!evaluation) { - return { allowPost: true }; - } - - if (evaluation.action === 'blocked' || evaluation.action === 'logged') { - const allowPost = evaluation.action === 'blocked' ? false : true; - await AutomodLog.create({ - action: evaluation.action, - author: post.pid, - post_id: post.id, - post_content_body: post.body ?? '', - created_at: new Date(), - rule_id: evaluation.violatedRule.id, - parent_post_id: post.parent ?? null, - community_id: post.community_id, - matches: evaluation.matches.map(match => ({ - start: match.start, - end: match.end - })) - }); - return { - allowPost - }; - } - - throw new Error('Invalid automod evaluation'); -} - -/** - * Deletes undefined, but present, values. Useful for Mongoose queries. - * @param obj Your object. - * @returns Partial, with undefined values deleted - */ -export function deleteOptional(obj: T): Partial { // Partial kinda wrong but good enough - for (const _key of Object.keys(obj)) { - const key = _key as keyof T; - if (obj[key] === undefined) { - delete obj[key]; - } - } - return obj; -} - export function fixupUnicodes(input: string): string { // 202F NARROW NON BREAKING SPACE // -> normal NBSP (Cemu doesn't render NNBSP right) diff --git a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss index eea0c4fd..5a2fa1e8 100644 --- a/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss +++ b/apps/juxtaposition-ui/webfiles/ctr/css/juxt.scss @@ -13,23 +13,26 @@ body { margin: 0; - font-family: nintendo_NTLG-DB_001,sans-serif; + font-family: nintendo_NTLG-DB_001, sans-serif; width: 400px; background: #00f } #body, body { - overflow-x: clip; /* Doesn't work on CTR, but the CSS relies on it not working */ + overflow-x: clip; + /* Doesn't work on CTR, but the CSS relies on it not working */ } #body { background-image: url(@/images/background.png); background-repeat: repeat; - padding-bottom: 28px; /* Toolbar location */ + padding-bottom: 28px; + /* Toolbar location */ -webkit-box-sizing: border-box; - min-height: 460px; /* Never show the blue background! */ + min-height: 460px; + /* Never show the blue background! */ } a { @@ -64,12 +67,12 @@ ul { padding: 0 } -.headline > h2 { +.headline>h2 { font-size: 18px; padding: 0 10px } -#body > .body-content { +#body>.body-content { width: 322px; margin-left: 39px; min-height: 175px; @@ -95,13 +98,13 @@ menu.tab-header { border-color: #59c9a5 } -.icon-container > .icon { +.icon-container>.icon { width: 64px; height: 64px; background: #e6eaf1 } -.list-content-with-icon-column > li { +.list-content-with-icon-column>li { display: table; width: 300px; border-bottom: 3px dotted #d2b0e1; @@ -144,7 +147,8 @@ menu.tab-header { right: 10px; top: 2px; } -.text > span > span { + +.text>span>span { vertical-align: middle; } @@ -197,7 +201,7 @@ menu.tab-header.user-page li:first-child { } menu.tab-header.user-page li.double, -menu.tab-header.user-page li.double > a { +menu.tab-header.user-page li.double>a { width: 146px } @@ -264,33 +268,30 @@ menu.tab-header.user-page li:last-child { margin-top: 10px; } -.post header > a, -.tags { +.post header>a { display: inline-block; - position: absolute } -.post header > a { - width: 258px; - vertical-align: middle; - overflow: clip; - left: 0; - top: 17px -} +.tags-container { + >.sprite { + margin-right: 5px; + position: relative; + top: 3px; + } -.tags { - color: #a362d8; - font-size: 10px; - line-height: 12px; - margin-top: 1px; - margin-left: 0 + .tags { + display: inline-block; + color: #a362d8; + font-size: 10px; + line-height: 12px; + } } .post-content-text { padding: 5px; font-size: 15px; - word-wrap: break-word; - white-space: pre-wrap; + word-wrap: break-word; + white-space: pre-wrap; } .post header { @@ -349,7 +350,7 @@ menu.tab-header.user-page li:last-child { display: inline-block } -.spoiler-wrapper > button { +.spoiler-wrapper>button { position: relative; z-index: 2; font-size: 12px; @@ -462,23 +463,26 @@ menu.tab-header.no-margin { font-weight: 400; color: #969696; } + .body.message span { font-size: 10px; color: #646464; } + .body.message .nick-name { font-size: 14px; color: #000; } + .body .nick-name { color: #000; } -.settings-list > li { +.settings-list>li { display: inline-block; } p.settings-label { - float: left; - width: 275px; + float: left; + width: 275px; } \ No newline at end of file diff --git a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css index b444fd61..62cfa68c 100644 --- a/apps/juxtaposition-ui/webfiles/portal/css/juxt.css +++ b/apps/juxtaposition-ui/webfiles/portal/css/juxt.css @@ -512,12 +512,13 @@ body { } header svg { fill:#a362d8; - -webkit-transform:translate(10px,10px); - margin-left:-10px + -webkit-transform:translate(0px,10px) scale(0.8); + margin-left: 15px; + margin-right: 1px; } .tags, header svg { - color:#a362d8 + color:#a362d8; } .post-list .post .no-play, .post-list .post .screen-name { diff --git a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js index d23597a1..748fa19c 100644 --- a/apps/juxtaposition-ui/webfiles/portal/js/juxt.js +++ b/apps/juxtaposition-ui/webfiles/portal/js/juxt.js @@ -6,7 +6,7 @@ import { initPostPageView } from './post'; import { initNavTabs } from './components/ui/PortalNavTabs'; import { initSearchForm } from './components/ui/PortalSearchForm'; import { initNavBar } from './components/PortalNavBar'; -import { back } from './nav'; +import { back, exit } from './nav'; export var pjax; setInterval(checkForUpdates, 30000); @@ -329,17 +329,7 @@ window.stopLoading = stopLoading; function reportPost(post) { var id = post.getAttribute('data-post'); - var button = document.getElementById('report-launcher'); - var form = document.getElementById('report-form'); - var formID = document.getElementById('report-post-id'); - if (!id || !button || !form || !formID) { - return; - } - - form.action = '/posts/' + id + '/report'; - formID.value = id; - console.log(id.replace(/(\d{3})(\d{4})(\d{3})(\d{4})(\d{3})(\d{4})/, '$1-$2-$3-$4-$5-$6')); - button.click(); + pjax.loadUrl('/posts/' + id + '/report'); } window.reportPost = reportPost; @@ -377,3 +367,8 @@ function input() { back(); } } + +function exitApplet() { + exit(); +} +window.exitApplet = exitApplet; diff --git a/apps/juxtaposition-ui/webfiles/web/css/web.scss b/apps/juxtaposition-ui/webfiles/web/css/web.scss index e281a7b3..c1805f91 100644 --- a/apps/juxtaposition-ui/webfiles/web/css/web.scss +++ b/apps/juxtaposition-ui/webfiles/web/css/web.scss @@ -262,6 +262,10 @@ header>.selected svg { display: block; } +.icon-container.notify { + margin: auto; +} + .user-icon.verified { border: solid 4px var(--btn); } @@ -749,7 +753,7 @@ button.report { align-items: center; width: 100%; justify-content: center; - border-radius: 0 20px 20px 0; + border-radius: 10px; } .add-post-page .textarea-text { @@ -759,7 +763,7 @@ button.report { padding: 1ch; border: none; resize: none; - border-radius: 0 20px 20px 0; + border-radius: 10px; background: #f9f5fb; } @@ -1872,6 +1876,52 @@ button.report { z-index: 100; } +.reply-control-bar { + background: var(--background-dark); + border-radius: 8px; + padding: 0.6rem 1.5rem; + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; + + .reply-control-title { + flex: 1; + font-size: 14px; + } + + .reply-control-item { + transition: background ease-in-out 100ms, transform ease-in-out 100ms; + padding: 0.5rem 1rem; + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 14px; + + &:hover { + background: var(--background-alt); + } + + &:active { + transform: scale(0.95); + } + } + + .reply-icon { + color: var(--text-light); + font-size: 1.3em; + line-height: 0.7; + margin-right: 0.2em; + margin-left: -0.2em; + + &.small { + font-size: 1em; + transform: scale(0.8); + } + } +} + @include t.media(">=tablet", " void): void { + POST(`/posts/${id}/edit/unspoiler`, '', (xhr) => { + if (xhr.status !== 200) { + return cb(); + } + let res: UnspoilerPostResponse; + + try { + res = JSON.parse(xhr.responseText); + } catch (e) { + console.error(e); + return cb(); + } + + if (!res.success) { + return cb(); + } + + cb(); + }); +} + +export type SpoilerPostResponse = { + success: boolean; +}; + +export function spoilerPostById(id: string, cb: () => void): void { + POST(`/posts/${id}/edit/spoiler`, '', (xhr) => { + if (xhr.status !== 200) { + return cb(); + } + let res: SpoilerPostResponse; + + try { + res = JSON.parse(xhr.responseText); + } catch (e) { + console.error(e); + return cb(); + } + + if (!res.success) { + return cb(); + } + + cb(); + }); +} diff --git a/apps/juxtaposition-ui/webfiles/web/js/login.js b/apps/juxtaposition-ui/webfiles/web/js/login.js index b28b484c..c85f4454 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/login.js +++ b/apps/juxtaposition-ui/webfiles/web/js/login.js @@ -1,31 +1,4 @@ -// Toast code duplicated at web.js -function Toast(text, ms) { - const x = document.getElementById('toast'); - x.innerText = text; - x.className = 'show'; - startHideToast(ms ? ms : 3000); -} - -function initToast() { - const x = document.getElementById('toast'); - if (!x) { - return; // No toast on screen - } - const attr = x.getAttribute('data-show'); - if (!attr) { - return; // Nothing to do - } - - x.removeAttribute('data-show'); - setTimeout(() => Toast(x.innerText, 20000), 100); // Show after a delay so it shows an animation -} - -function startHideToast(ms) { - setTimeout(function () { - const x = document.getElementById('toast'); - x.className = x.className.replace('show', ''); - }, ms); -} +import { initToast } from './toast'; document.addEventListener('DOMContentLoaded', () => { initToast(); diff --git a/apps/juxtaposition-ui/webfiles/web/js/reports.js b/apps/juxtaposition-ui/webfiles/web/js/reports.js index e182ad4e..378eefe6 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/reports.js +++ b/apps/juxtaposition-ui/webfiles/web/js/reports.js @@ -1,3 +1,6 @@ +import { Toast } from './toast'; +import { POST } from './xhr'; + export function initReportForm() { const modal = document.getElementById('report-form-modal'); if (!modal || modal.setupDone) { @@ -9,6 +12,26 @@ export function initReportForm() { modal.hidden = true; }); + const form = modal.querySelector('form'); + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + + const formData = new FormData(form); + const params = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + params.append(key, value); + } + + POST(form.action, params.toString(), (request) => { + if (request.status !== 200) { + Toast('Unable to submit report. Please try again later.'); + return; + } + Toast('Report submitted.'); + modal.hidden = true; + }); + }); + modal.setupDone = true; } @@ -20,8 +43,9 @@ export function reportPost(id) { return; } - form.action = `/posts/${id}/report`; + form.action = `/posts/${id}/report?api=true`; formID.value = id; modal.hidden = false; + form.reset(); } diff --git a/apps/juxtaposition-ui/webfiles/web/js/toast.ts b/apps/juxtaposition-ui/webfiles/web/js/toast.ts new file mode 100644 index 00000000..6caa9681 --- /dev/null +++ b/apps/juxtaposition-ui/webfiles/web/js/toast.ts @@ -0,0 +1,31 @@ +export function Toast(text: string, ms?: number): void { + const toastElement = document.getElementById('toast'); + if (!toastElement) { + return; + } + toastElement.innerText = text; + toastElement.className = 'show'; + startHideToast(ms ?? 3000); +} + +export function initToast(): void { + const toastElement = document.getElementById('toast'); + if (!toastElement) { + return; + } + const attr = toastElement.getAttribute('data-show'); + if (!attr) { + return; + } + toastElement.removeAttribute('data-show'); + setTimeout(() => Toast(toastElement.innerText, 20000), 100); +} + +function startHideToast(ms: number): void { + setTimeout(function () { + const toastElement = document.getElementById('toast'); + if (toastElement) { + toastElement.className = toastElement.className.replace('show', ''); + } + }, ms); +} diff --git a/apps/juxtaposition-ui/webfiles/web/js/web.js b/apps/juxtaposition-ui/webfiles/web/js/web.js index c6cc5af0..ba79bdd4 100644 --- a/apps/juxtaposition-ui/webfiles/web/js/web.js +++ b/apps/juxtaposition-ui/webfiles/web/js/web.js @@ -1,9 +1,10 @@ import { popupItemCb, setupPopup } from './menus'; import { initReportForm, reportPost } from './reports'; import { POST, GET } from './xhr'; -import { deletePostById } from './api'; +import { deletePostById, spoilerPostById, unspoilerPostById } from './api'; import { initYeahButton } from './post'; import { initSearchForm } from './components/ui/WebSearchForm'; +import { Toast, initToast } from './toast'; setInterval(checkForUpdates, 30000); @@ -97,6 +98,18 @@ function initPopupMenus() { popupItemCb(menu.querySelector('[data-action="copy"]'), (_item, _ev) => { copyToClipboard(`${window.location.origin}/posts/${post}`); }); + popupItemCb(menu.querySelector('[data-action="spoiler"]'), (_item, _ev) => { + spoilerPostById(post, () => { + alert('Spoiler has been successfully added to the post'); + window.location.reload(); + }); + }); + popupItemCb(menu.querySelector('[data-action="unspoiler"]'), (_item, _ev) => { + unspoilerPostById(post, () => { + alert('Spoiler has been successfully removed from post'); + window.location.reload(); + }); + }); }); } @@ -259,34 +272,6 @@ function copyToClipboard(text) { Toast('Copied to clipboard.'); } -function Toast(text, ms) { - const x = document.getElementById('toast'); - x.innerText = text; - x.className = 'show'; - startHideToast(ms ? ms : 3000); -} - -function initToast() { - const x = document.getElementById('toast'); - if (!x) { - return; // No toast on screen - } - const attr = x.getAttribute('data-show'); - if (!attr) { - return; // Nothing to do - } - - x.removeAttribute('data-show'); - setTimeout(() => Toast(x.innerText, 20000), 100); // Show after a delay so it shows an animation -} - -function startHideToast(ms) { - setTimeout(function () { - const x = document.getElementById('toast'); - x.className = x.className.replace('show', ''); - }, ms); -} - function downloadURI(uri, name) { const link = document.createElement('a'); link.download = name; diff --git a/apps/miiverse-api/eslint.config.mjs b/apps/miiverse-api/eslint.config.mjs index 14a0ab00..9e704ea7 100644 --- a/apps/miiverse-api/eslint.config.mjs +++ b/apps/miiverse-api/eslint.config.mjs @@ -8,5 +8,8 @@ export default defineConfig([ // Zod has too complex types to make a return type '@typescript-eslint/explicit-function-return-type': 'off' } + }, + { + ignores: ['src/prisma'] } ]); diff --git a/apps/miiverse-api/package.json b/apps/miiverse-api/package.json index cd84eb3e..cfe2445c 100644 --- a/apps/miiverse-api/package.json +++ b/apps/miiverse-api/package.json @@ -6,12 +6,16 @@ "type": "module", "exports": null, "scripts": { - "dev": "tsup --watch --onSuccess \"node --enable-source-maps dist/server.js\"", + "start": "node --enable-source-maps dist/server.js", + "build": "prisma generate && tsup && tsc --noEmit", + "dev": "prisma generate && tsup --watch --onSuccess \"npm start\"", + "migration:create": "prisma migrate dev --create-only && prisma generate", + "migration:deploy": "prisma migrate deploy", + "migration:reset": "prisma migrate reset", + "db:seed": "tsup && node --enable-source-maps dist/seed.entry.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", - "build": "tsup && tsc --noEmit", - "start": "node --enable-source-maps dist/server.js", - "test": "ts-node test/test.ts" + "postinstall": "prisma generate" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", @@ -19,6 +23,8 @@ "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", "@pretendonetwork/grpc": "^2.4.3", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@repo/grpc-client": "^0.0.0", "crc": "^4.3.2", "express": "^5.2.1", @@ -31,6 +37,7 @@ "nice-grpc": "^2.1.14", "node-snowflake": "0.0.1", "pako": "^2.1.0", + "pg": "^8.22.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -44,9 +51,11 @@ "@types/express": "^4.17.17", "@types/multer": "^2.1.0", "@types/pako": "^2.0.4", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "eslint": "^9.39.4", + "prisma": "^7.8.0", "tsup": "^8.5.1", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/apps/miiverse-api/prisma.config.ts b/apps/miiverse-api/prisma.config.ts new file mode 100644 index 00000000..a2145aec --- /dev/null +++ b/apps/miiverse-api/prisma.config.ts @@ -0,0 +1,55 @@ +/* + * This file needs to be indivually compilable and runnable, so this does not depend on any existing project files + */ + +import { createConfig, loaders, zodV4SchemaToTransformer } from '@neato/config'; +import { defineConfig } from 'prisma/config'; +import { z } from 'zod'; +import type { SchemaTransformer, SchemaTransformerContext } from '@neato/config'; + +const schema = z.object({ + db: z.object({ + url: z.string().optional() + }).prefault({}) +}); + +const dockerPreset: z.input = { + db: { + url: 'postgresql://postgres:postgres@localhost:5432/miiverse' + } +}; + +function flatZodSchema(schema: T): SchemaTransformer> { + const transformer = zodV4SchemaToTransformer>(schema); + return { + extract: () => transformer.extract().map(v => ({ + ...v, + normalizedKey: v.normalizedKey.replaceAll('__', '_') + })), + validate: (ctx: SchemaTransformerContext) => transformer.validate(ctx) + }; +} + +const config = createConfig({ + envPrefix: 'PN_MIIVERSE_API_', + presetKey: 'usePresets', + presets: { + docker: dockerPreset + }, + loaders: [ + loaders.environment(), + loaders.file('.env'), + loaders.file('config.json') + ], + schema: flatZodSchema(schema) +}); + +export default defineConfig({ + schema: 'src/models/schema.prisma', + migrations: { + path: 'src/models/migrations' + }, + datasource: { + url: config.db.url + } +}); diff --git a/apps/miiverse-api/src/config.ts b/apps/miiverse-api/src/config.ts index b202d5ec..12c7796d 100644 --- a/apps/miiverse-api/src/config.ts +++ b/apps/miiverse-api/src/config.ts @@ -30,6 +30,10 @@ const schema = z.object({ mongoose: z.object({ uri: z.string() }), + db: z.object({ + /** Postgres connection string */ + url: z.string() + }), s3: z.object({ endpoint: z.string(), key: z.string(), @@ -69,6 +73,9 @@ export const presets: Record = { mongoose: { uri: 'mongodb://localhost:27017/miiverse?directConnection=true' }, + db: { + url: 'postgres://postgres:postgres@localhost:5432/miiverse' + }, s3: { endpoint: 'http://localhost:9000', key: 'minioadmin', diff --git a/apps/miiverse-api/src/database.ts b/apps/miiverse-api/src/database.ts index a92c5282..78edf4c0 100644 --- a/apps/miiverse-api/src/database.ts +++ b/apps/miiverse-api/src/database.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import { PrismaPg } from '@prisma/adapter-pg'; import { logger } from '@/logger'; import { Community } from '@/models/community'; import { Content } from '@/models/content'; @@ -7,6 +8,7 @@ import { Endpoint } from '@/models/endpoint'; import { Post } from '@/models/post'; import { Settings } from '@/models/settings'; import { config } from '@/config'; +import { PrismaClient } from '@/prisma/client'; import type { HydratedEndpointDocument } from '@/models/endpoint'; import type { HydratedConversationDocument } from '@/models/conversation'; import type { HydratedContentDocument } from '@/types/mongoose/content'; @@ -14,6 +16,27 @@ import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +let prisma: PrismaClient | null = null; + +export function getDb() { + if (!prisma) { + throw new Error('Prisma used before initialisation'); + } + return prisma; +} + +export async function setupPrisma() { + const adapter = new PrismaPg({ connectionString: config.db.url }); + prisma = new PrismaClient({ adapter }); + + try { + await prisma.$connect(); + logger?.info('Connected to database!'); + } catch (err) { + logger?.error(err, 'Failed to connect to database, continueing anyway!'); + } +} + let connection: mongoose.Connection; mongoose.set('strictQuery', true); diff --git a/apps/miiverse-api/src/metrics.ts b/apps/miiverse-api/src/metrics.ts new file mode 100644 index 00000000..221a5868 --- /dev/null +++ b/apps/miiverse-api/src/metrics.ts @@ -0,0 +1,173 @@ +import { Gauge } from 'prom-client'; +import expressMetrics from 'express-prom-bundle'; +import express from 'express'; +import { logger } from '@/logger'; +import { config } from '@/config'; +import { Settings } from '@/models/settings'; +import { Post } from '@/models/post'; +import type { Express, NextFunction, Request, Response } from 'express'; + +// This file contains juxtaposition_ui prefixed metrics. +// This is for backwards compatibility from when they were in the juxtaposition-ui project + +export const onlineNowGauge = new Gauge({ + name: 'juxtaposition_online_users_now', + help: 'Users active in the last 10 minutes', + async collect(): Promise { + const onlineRangeMs = 10 * 60 * 1000; // 10 minutes + const cutoff = new Date(Date.now() - onlineRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const activeMonthlyGauge = new Gauge({ + name: 'juxtaposition_active_users_monthly', + help: 'Users active in the last 30 days', + async collect(): Promise { + const monthlyRangeMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - monthlyRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const activeYearlyGauge = new Gauge({ + name: 'juxtaposition_active_users_yearly', + help: 'Users active in the last 365 days', + async collect(): Promise { + const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - yearlyRangeMs); + const [result] = await Settings.aggregate<{ n: number } | undefined>([ + { $match: { last_active: { $gt: cutoff } } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const totalUsersGauge = new Gauge({ + name: 'juxtaposition_user_count_total', + help: 'Total number of registered users', + async collect(): Promise { + const count = await Settings.estimatedDocumentCount(); + this.set(count); + } +}); + +export async function getUserMetrics(): Promise<{ totalUsers: number; currentOnlineUsers: number }> { + // This assumes that both gauges have no labels + return { + totalUsers: (await totalUsersGauge.get()).values[0].value, + currentOnlineUsers: (await onlineNowGauge.get()).values[0].value + }; +} + +export const dailyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_daily', + help: 'New posts in the last 24 hours', + async collect(): Promise { + const dailyRangeMs = 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - dailyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const monthlyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_monthly', + help: 'New posts in the last 30 days', + async collect(): Promise { + const monthyRangeMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - monthyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const yearlyPostGauge = new Gauge({ + name: 'juxtaposition_post_count_yearly', + help: 'New posts in the last 365 days', + async collect(): Promise { + const yearlyRangeMs = 365 * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - yearlyRangeMs); + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { created_at: { $gt: cutoff }, message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export const totalPostsGauge = new Gauge({ + name: 'juxtaposition_post_count_total', + help: 'Total number of posts', + async collect(): Promise { + const [result] = await Post.aggregate<{ n: number } | undefined>([ + { $match: { message_to_pid: null } }, + { $count: 'n' } + ]); + this.set(result?.n ?? 0); + } +}); + +export async function getPostMetrics(): Promise<{ totalPosts: number; dailyPosts: number }> { + // This assumes that both gauges have no labels + return { + totalPosts: (await totalPostsGauge.get()).values[0].value, + dailyPosts: (await dailyPostGauge.get()).values[0].value + }; +} + +export function registerMetrics(app: Express): Express { + const metrics = express(); + + if (config.metrics.enabled) { + logger.info('Setting up metrics'); + app.use(expressMetrics({ + // Include full express and nodejs metrics + includeMethod: true, + includePath: true, + urlValueParser: { + minBase64Length: 15 + }, + promClient: { + collectDefaultMetrics: {} + }, + + // Keep metrics on a different app (so they aren't exposed) + autoregister: false, + metricsApp: metrics + })); + } + + metrics.use((error: Error, req: Request, res: Response, _next: NextFunction) => { + logger.error(error, 'Request failed!'); + res.sendStatus(500); + }); + + return metrics; +} + +export function listenMetrics(metricsApp: Express): void { + if (!config.metrics.enabled) { + return; + } + + const port = config.metrics.port; + metricsApp.listen(port, '0.0.0.0', () => { + logger.success(`Metrics server started on port ${port}`); + }); +} diff --git a/apps/miiverse-api/src/models/community.ts b/apps/miiverse-api/src/models/community.ts index 68351250..1e6c986e 100644 --- a/apps/miiverse-api/src/models/community.ts +++ b/apps/miiverse-api/src/models/community.ts @@ -94,7 +94,7 @@ const CommunitySchema = new Schema new Date() }, empathy_count: { type: Number, diff --git a/apps/miiverse-api/src/models/conversation.ts b/apps/miiverse-api/src/models/conversation.ts index 5baeea36..a2c6d4e7 100644 --- a/apps/miiverse-api/src/models/conversation.ts +++ b/apps/miiverse-api/src/models/conversation.ts @@ -40,11 +40,11 @@ export const ConversationSchema = new Schema({ }, created_at: { type: Date, - default: new Date() + default: (): Date => new Date() }, last_updated: { type: Date, - default: new Date() + default: (): Date => new Date() }, message_preview: { type: String, diff --git a/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql b/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql new file mode 100644 index 00000000..4085bc79 --- /dev/null +++ b/apps/miiverse-api/src/models/migrations/20260702144608_notifications/migration.sql @@ -0,0 +1,29 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('Follow', 'LimitedFromPosting', 'PostDeleted', 'System'); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "content" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_recipients" ( + "id" TEXT NOT NULL, + "pid" INTEGER NOT NULL, + "has_read" BOOLEAN NOT NULL DEFAULT false, + "notification_id" TEXT NOT NULL, + + CONSTRAINT "notification_recipients_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notification_recipients_pid_idx" ON "notification_recipients"("pid"); + +-- AddForeignKey +ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/miiverse-api/src/models/migrations/migration_lock.toml b/apps/miiverse-api/src/models/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/apps/miiverse-api/src/models/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/miiverse-api/src/models/notification.ts b/apps/miiverse-api/src/models/notification.ts deleted file mode 100644 index e869ceb6..00000000 --- a/apps/miiverse-api/src/models/notification.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Schema, model } from 'mongoose'; -import type { INotification, NotificationModel } from '@/types/mongoose/notification'; - -const NotificationSchema = new Schema({ - pid: String, - type: String, - link: String, - image: String, - text: String, - objectID: String, - users: [{ - user: String, - timestamp: Date - }], - read: Boolean, - lastUpdated: Date -}); - -export const Notification = model('Notification', NotificationSchema); diff --git a/apps/miiverse-api/src/models/post.ts b/apps/miiverse-api/src/models/post.ts index 71ca472c..9d901ec6 100644 --- a/apps/miiverse-api/src/models/post.ts +++ b/apps/miiverse-api/src/models/post.ts @@ -124,6 +124,19 @@ PostSchema.index({ removed: 1 }); +// Index for post count on community page +PostSchema.index({ + community_id: 1, + removed: 1, + parent: 1 +}); + +// Index for post metrics +PostSchema.index({ + message_to_pid: 1, + created_at: 1 +}); + // posts.list PostSchema.index({ pid: 1, diff --git a/apps/miiverse-api/src/models/report.ts b/apps/miiverse-api/src/models/report.ts index e6a15767..55ad0905 100644 --- a/apps/miiverse-api/src/models/report.ts +++ b/apps/miiverse-api/src/models/report.ts @@ -20,12 +20,12 @@ export const ReportSchema = new Schema({ }, message: { type: String, - required: true + default: '' }, created_at: { type: Date, required: true, - default: new Date() + default: (): Date => new Date() }, resolved: { type: Boolean, diff --git a/apps/miiverse-api/src/models/schema.prisma b/apps/miiverse-api/src/models/schema.prisma new file mode 100644 index 00000000..034dc556 --- /dev/null +++ b/apps/miiverse-api/src/models/schema.prisma @@ -0,0 +1,41 @@ +generator client { + provider = "prisma-client" + output = "../prisma" +} + +datasource db { + provider = "postgresql" +} + +enum NotificationType { + Follow + LimitedFromPosting + PostDeleted + System +} + +/// Notification content +model Notification { + id String @id + type NotificationType + content Json /// Any extra context, dependent on type. Like users who followed + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + notificationRecipients NotificationRecipient[] + + @@map("notifications") +} + +/// Received notification +model NotificationRecipient { + id String @id + pid Int + hasRead Boolean @default(false) @map("has_read") + + notificationId String @map("notification_id") + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@index([pid]) + @@map("notification_recipients") +} diff --git a/apps/miiverse-api/src/models/settings.ts b/apps/miiverse-api/src/models/settings.ts index dd1d60ed..f32e30cc 100644 --- a/apps/miiverse-api/src/models/settings.ts +++ b/apps/miiverse-api/src/models/settings.ts @@ -64,11 +64,11 @@ const SettingsSchema = new Schema({ }, created_at: { type: Date, - default: Date.now() + default: (): Date => new Date() }, last_active: { type: Date, - default: Date.now(), + default: (): Date => new Date(), index: true } }); diff --git a/apps/miiverse-api/src/server.entry.ts b/apps/miiverse-api/src/server.entry.ts index a74096c2..348b4699 100644 --- a/apps/miiverse-api/src/server.entry.ts +++ b/apps/miiverse-api/src/server.entry.ts @@ -1,7 +1,6 @@ import '@/extend-zod'; // Needs to be the first import import express from 'express'; -import expressMetrics from 'express-prom-bundle'; -import { connect as connectDatabase } from '@/database'; +import { connect as connectDatabase, setupPrisma } from '@/database'; import { logger } from '@/logger'; import { loggerHttp } from '@/loggerHttp'; import auth from '@/middleware/auth'; @@ -14,30 +13,12 @@ import { initImageProcessing } from '@/images'; import { ApiErrorCode, badRequest, serverError } from '@/errors'; import { connectGrpc } from '@/grpc'; import { setupS3 } from '@/s3'; +import { listenMetrics, registerMetrics } from '@/metrics'; -const { http: { port }, metrics: { enabled: metricsEnabled, port: metricsPort } } = config; -const metrics = express(); const app = express(); // Metrics has to happen first so we can measure the other middleware -if (metricsEnabled) { - logger.info('Setting up metrics'); - app.use(expressMetrics({ - // Include full express and nodejs metrics - includeMethod: true, - includePath: true, - urlValueParser: { - minBase64Length: 15 - }, - promClient: { - collectDefaultMetrics: {} - }, - - // Keep metrics on a different app (so they aren't exposed) - autoregister: false, - metricsApp: metrics - })); -} +const metricsApp = registerMetrics(app); app.set('etag', false); app.set('trust proxy', config.http.trustProxy); @@ -84,19 +65,15 @@ async function main(): Promise { setupS3(); connectGrpc(); await connectDatabase(); + await setupPrisma(); await initImageProcessing(); - app.listen(port, '0.0.0.0', () => { - logger.info(`Server started on port ${port}`); + app.listen(config.http.port, '0.0.0.0', () => { + logger.info(`Server started on port ${config.http.port}`); }); + listenMetrics(metricsApp); await setupGrpc(); - - if (metricsEnabled) { - metrics.listen(metricsPort, () => { - logger.info(`Metrics server started on port ${metricsPort}`); - }); - } } process.title = 'Pretendo - Miiverse'; diff --git a/apps/miiverse-api/src/services/api/routes/posts.ts b/apps/miiverse-api/src/services/api/routes/posts.ts index c91eef83..57841c70 100644 --- a/apps/miiverse-api/src/services/api/routes/posts.ts +++ b/apps/miiverse-api/src/services/api/routes/posts.ts @@ -225,18 +225,22 @@ function canPost(community: HydratedCommunityDocument, userSettings: HydratedSet return isReply ? isOpenCommunity : isPublicPostableCommunity; } -export function getShotMode(community: HydratedCommunityDocument, pack: ParamPack | null): CommunityShotMode { - if (pack === null) { +export function getShotModeForTitleId(community: HydratedCommunityDocument, titleId: string): CommunityShotMode { + // Shots only on matching communities + if (!community.title_id.includes(titleId) && + !community.shot_extra_title_id?.includes(titleId)) { return 'block'; } - // Shots only on matching communities - if (!community.title_id.includes(pack.title_id) && - !community.shot_extra_title_id?.includes(pack.title_id)) { + return community.shot_mode as CommunityShotMode; // type validated by database +} + +export function getShotMode(community: HydratedCommunityDocument, pack: ParamPack | null): CommunityShotMode { + if (pack === null) { return 'block'; } - return community.shot_mode as CommunityShotMode; // type validated by database + return getShotModeForTitleId(community, pack.title_id); } async function newPost(request: express.Request, response: express.Response): Promise { diff --git a/apps/miiverse-api/src/services/internal/builder/context.ts b/apps/miiverse-api/src/services/internal/builder/context.ts index 9b9e1c8f..7ae35f8e 100644 --- a/apps/miiverse-api/src/services/internal/builder/context.ts +++ b/apps/miiverse-api/src/services/internal/builder/context.ts @@ -1,5 +1,7 @@ +import { getDb } from '@/database'; import type { Request, Response } from 'express'; import type { z } from 'zod'; +import type { PrismaClient } from '@/prisma/client'; export type ZodRouteSchemaSchape = { body?: z.ZodType; @@ -12,6 +14,7 @@ export type ZodRouteContext; query: z.infer; params: z.infer; + db: PrismaClient; auth: TAuthCtx; }; @@ -24,6 +27,7 @@ export function createRouteContext; } diff --git a/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts b/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts new file mode 100644 index 00000000..5776eef2 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/admin/adminStats.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const adminStatsSchema = z.object({ + dailyPosts: z.number(), + totalPosts: z.number(), + onlineUsers: z.number(), + totalUsers: z.number() +}).openapi('AdminStats'); + +export type AdminStatsDto = z.infer; + +export function mapAdminStats(stats: AdminStatsDto): AdminStatsDto { + return stats; +} diff --git a/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts b/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts index ecb2b0e2..0fa87750 100644 --- a/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts +++ b/apps/miiverse-api/src/services/internal/contract/admin/automodLog.ts @@ -2,8 +2,10 @@ import { z } from 'zod'; import { mapShallowAutomodRule, shallowAutomodRuleSchema } from '@/services/internal/contract/admin/automodRule'; import { automodAction } from '@/models/automodLog'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { HydratedAutomodLogDocument } from '@/models/automodLog'; import type { HydratedAutomodRuleDocument } from '@/models/automodRules'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const automodActionEnum = asOpenapi('AutomodActionEnum', z.enum(automodAction)); @@ -12,7 +14,7 @@ export const automodLogSchema = z.object({ createdAt: z.date(), rule: shallowAutomodRuleSchema.nullable(), action: automodActionEnum, - postAuthor: z.number(), + postAuthor: shallowUserSchema.nullable(), postId: z.string().nullable(), parentPostId: z.string().nullable(), communityId: z.string().nullable(), @@ -27,13 +29,13 @@ export const automodLogSchema = z.object({ export type AutomodLogDto = z.infer; -export function mapAutomodLog(log: HydratedAutomodLogDocument, rule: HydratedAutomodRuleDocument | null): AutomodLogDto { +export function mapAutomodLog(log: HydratedAutomodLogDocument, user: HydratedSettingsDocument | null, rule: HydratedAutomodRuleDocument | null): AutomodLogDto { return { id: log.id, createdAt: log.created_at, rule: rule ? mapShallowAutomodRule(rule) : null, action: log.action, - postAuthor: log.author, + postAuthor: user ? mapShallowUser(user) : null, postId: log.action === 'blocked' ? null : log.post_id, parentPostId: log.parent_post_id, communityId: log.community_id, diff --git a/apps/miiverse-api/src/services/internal/contract/admin/report.ts b/apps/miiverse-api/src/services/internal/contract/admin/report.ts index 6ad26dec..d3184a8a 100644 --- a/apps/miiverse-api/src/services/internal/contract/admin/report.ts +++ b/apps/miiverse-api/src/services/internal/contract/admin/report.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { HydratedReportDocument } from '@/types/mongoose/report'; import type { HydratedPostDocument } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const reportSchema = z.object({ id: z.string(), @@ -9,6 +12,7 @@ export const reportSchema = z.object({ post: postSchema.nullable(), reporter: z.object({ pid: z.number(), + user: shallowUserSchema.nullable(), reasonId: z.number(), message: z.string() }), @@ -16,6 +20,7 @@ export const reportSchema = z.object({ isResolved: z.boolean(), resolvedAt: z.date().nullable(), pid: z.number().nullable(), + user: shallowUserSchema.nullable(), note: z.string().nullable(), reason: z.enum(['reportResolved', 'similarReportResolved']).nullable() }) @@ -23,24 +28,32 @@ export const reportSchema = z.object({ export type ReportDto = z.infer; -export function mapReport(report: HydratedReportDocument, post: HydratedPostDocument | null): ReportDto { +export function mapReport(report: HydratedReportDocument, users: HydratedSettingsDocument[], post: HydratedPostDocument | null, community: HydratedCommunityDocument | null): ReportDto { const hasPost = post && !post.removed ? post : null; const isResolved = report.resolved || !hasPost; + + const reporter = users.find(v => v.pid === report.reported_by); + const resolver = report.resolved_by ? users.find(v => v.pid === report.resolved_by) : null; + + const remover = post?.removed_by ? users.find(v => v.pid === post.removed_by) ?? null : null; + return { id: report.id, createdAt: report.created_at, reporter: { pid: report.reported_by, + user: reporter ? mapShallowUser(reporter) : null, reasonId: report.reason, message: report.message }, resolved: { resolvedAt: report.resolved_at ?? null, pid: report.resolved_by ?? null, + user: resolver ? mapShallowUser(resolver) : null, isResolved: isResolved, note: report.note ?? null, reason: report.resolved ? 'reportResolved' : 'similarReportResolved' }, - post: post ? mapPost(post) : null + post: post ? mapPostWithModeration(post, community, remover) : null }; } diff --git a/apps/miiverse-api/src/services/internal/contract/community.ts b/apps/miiverse-api/src/services/internal/contract/community.ts index 0797f941..84b2be37 100644 --- a/apps/miiverse-api/src/services/internal/contract/community.ts +++ b/apps/miiverse-api/src/services/internal/contract/community.ts @@ -29,7 +29,7 @@ export const communityIconsSchema = z.object({ }).openapi('CommunityIcons'); export const communitySchema = asOpenapi('Community', z.object({ - id: z.string(), + unstable_id: z.string(), olive_community_id: z.string(), parentId: z.string().nullable(), permissions: communityPermissionSchema, @@ -50,6 +50,16 @@ export const communitySchema = asOpenapi('Community', z.object({ export type CommunityDto = z.infer; +export const shallowCommunitySchema = asOpenapi('ShallowCommunity', z.object({ + unstable_id: z.string(), + olive_community_id: z.string(), + name: z.string(), + type: z.number(), + iconImagePaths: communityIconsSchema +})); + +export type ShallowCommunityDto = z.infer; + export const communityStatsSchema = z.object({ id: z.string(), olive_community_id: z.string(), @@ -80,23 +90,36 @@ export function mapCommunityType(type: number): CommunityCategoryEnum { export function mapCommunity(comm: HydratedCommunityDocument): CommunityDto { const imageId = comm.parent ? comm.parent : comm.olive_community_id; + const shallowCommunity = mapShallowCommunity(comm); return { - id: comm.community_id, - olive_community_id: comm.olive_community_id, + unstable_id: shallowCommunity.unstable_id, + olive_community_id: shallowCommunity.olive_community_id, + type: shallowCommunity.type, + name: shallowCommunity.name, + iconImagePaths: shallowCommunity.iconImagePaths, + parentId: comm.parent ?? null, titleIds: comm.title_id ?? [], - type: comm.type, permissions: comm.permissions, adminPids: comm.admins ?? [], category: mapCommunityType(comm.type), - name: comm.name, description: comm.description, followerCount: comm.followers, ctrHeaderImagePath: comm.ctr_header ?? `/headers/${imageId}/3DS.png`, hasLegacyCtrHeader: !comm.ctr_header, wupHeaderImagePath: comm.wup_header ?? `/headers/${imageId}/WiiU.png`, shotMode: comm.shot_mode ?? null, - extraShotTitleIds: comm.shot_extra_title_id ?? [], + extraShotTitleIds: comm.shot_extra_title_id ?? [] + }; +} + +export function mapShallowCommunity(comm: HydratedCommunityDocument): ShallowCommunityDto { + const imageId = comm.parent ? comm.parent : comm.olive_community_id; + return { + unstable_id: comm.community_id, + olive_community_id: comm.olive_community_id, + type: comm.type, + name: comm.name, iconImagePaths: { 32: comm.icon_paths?.[32] ?? `/icons/${imageId}/32.png`, 48: comm.icon_paths?.[48] ?? `/icons/${imageId}/48.png`, diff --git a/apps/miiverse-api/src/services/internal/contract/discovery.ts b/apps/miiverse-api/src/services/internal/contract/discovery.ts new file mode 100644 index 00000000..8c1f621e --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/discovery.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import type { HydratedEndpointDocument } from '@/models/endpoint'; + +export const discoveryStatusSchema = z.enum(['open', 'maintenance', 'closed', 'unavailable']).openapi('DiscoveryStatus'); +export type DiscoveryStatus = z.infer; + +export const discoverySchema = z.object({ + status: discoveryStatusSchema, + guestAccess: z.boolean(), + newUsers: z.boolean() +}).openapi('Discovery'); +export type DiscoveryDto = z.infer; + +export function mapDiscovery(endpoint: HydratedEndpointDocument | null): DiscoveryDto { + if (!endpoint) { + return { + status: 'unavailable', + guestAccess: false, + newUsers: false + }; + } + + const statusMap: Record = { + 3: 'maintenance', + 4: 'closed', + 0: 'open' + // No idea what other status there are + }; + + return { + status: statusMap[endpoint.status] ?? 'unavailable', + guestAccess: endpoint.guest_access, + newUsers: endpoint.new_users + }; +} diff --git a/apps/miiverse-api/src/services/internal/contract/follow.ts b/apps/miiverse-api/src/services/internal/contract/follow.ts index 8d11d77c..52a703af 100644 --- a/apps/miiverse-api/src/services/internal/contract/follow.ts +++ b/apps/miiverse-api/src/services/internal/contract/follow.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { IContent } from '@/types/mongoose/content'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; export const followActionSchema = z.enum(['follow', 'unfollow']).openapi('FollowActionEnum'); export type FollowAction = z.infer; @@ -18,3 +19,11 @@ export function mapFollowUser(action: FollowAction, targetUser: IContent): Follo follower_count: targetUser.following_users.length }; } + +export function mapFollowCommunity(action: FollowAction, target: HydratedCommunityDocument): FollowDto { + return { + action, + id: target.olive_community_id, + follower_count: target.followers + }; +} diff --git a/apps/miiverse-api/src/services/internal/contract/notification.ts b/apps/miiverse-api/src/services/internal/contract/notification.ts index 1a1d23b7..2f94f321 100644 --- a/apps/miiverse-api/src/services/internal/contract/notification.ts +++ b/apps/miiverse-api/src/services/internal/contract/notification.ts @@ -1,40 +1,119 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; -import type { INotification } from '@/types/mongoose/notification'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; +import type { Notification, NotificationRecipient } from '@/prisma/client'; +import type { FollowNotificationContent, LimitedFromPostingNotificationContent, PostDeletedNotificationContent, SystemNotificationContent } from '@/services/internal/utils/notifications'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; -export const notificationTypeSchema = asOpenapi('NotificationType', z.enum(['follow', 'notice'])); -export type NotificationType = z.infer; +export const followNotificationSchema = asOpenapi('FollowNotification', z.object({ + type: z.literal('follow'), + content: z.object({ + users: z.array(z.object({ + pid: z.number(), + timestamp: z.date(), + user: shallowUserSchema.optional() + })) + }) +})); + +export const systemNotificationSchema = asOpenapi('SystemNotification', z.object({ + type: z.literal('system'), + content: z.object({ + imagePath: z.string(), + link: z.string(), + text: z.string() + }) +})); + +export const postDeletedNotificationSchema = asOpenapi('PostDeletedNotification', z.object({ + type: z.literal('postDeleted'), + content: z.object({ + postId: z.string(), + reason: z.string().optional(), + postType: z.enum(['comment', 'post']) + }) +})); + +export const limitedFromPostingNotificationSchema = asOpenapi('LimitedFromPostingNotification', z.object({ + type: z.literal('limitedFromPosting'), + content: z.object({ + reason: z.string().optional(), + until: z.date().optional() + }) +})); export const notificationSchema = z.object({ - toPid: z.number(), - resourceId: z.string(), - link: z.string().nullable(), - imageUrl: z.string(), - content: z.string(), - read: z.boolean(), + pid: z.number(), + user: shallowUserSchema.optional(), + hasRead: z.boolean(), updatedAt: z.date(), - type: notificationTypeSchema, - users: z.array(z.object({ - pid: z.number(), - timestamp: z.date() - })) + notif: z.discriminatedUnion('type', [ + followNotificationSchema, + systemNotificationSchema, + postDeletedNotificationSchema, + limitedFromPostingNotificationSchema + ]) }).openapi('Notification'); export type NotificationDto = z.infer; -export function mapNotification(notif: INotification): NotificationDto { +export function mapNotification(recipient: NotificationRecipient, notif: Notification, users: HydratedSettingsDocument[]): NotificationDto { + let data: NotificationDto['notif'] | null = null; + + if (notif.type === 'Follow') { + const content = notif.content as FollowNotificationContent; + data = { + type: 'follow', + content: { + users: content.users.map((v) => { + const user = users.find(u => u.pid === v.pid); + return { + pid: v.pid, + timestamp: new Date(v.timestamp), + user: user ? mapShallowUser(user) : undefined + }; + }) + } + }; + } + + if (notif.type === 'System') { + const content = notif.content as SystemNotificationContent; + data = { + type: 'system', + content + }; + } + + if (notif.type === 'LimitedFromPosting') { + const content = notif.content as LimitedFromPostingNotificationContent; + data = { + type: 'limitedFromPosting', + content: { + reason: content.reason, + until: content.until ? new Date(content.until) : undefined + } + }; + } + + if (notif.type === 'PostDeleted') { + const content = notif.content as PostDeletedNotificationContent; + data = { + type: 'postDeleted', + content + }; + } + + if (!data) { + throw new Error(`No DTO mapping for notification ${notif.type} found`); + } + + const user = users.find(u => u.pid === recipient.pid); return { - toPid: Number(notif.pid), - resourceId: notif.objectID, - read: notif.read, - updatedAt: notif.lastUpdated, - link: notif.link, - type: notif.type as any, - users: notif.users.map(v => ({ - pid: Number(v.user), - timestamp: v.timestamp - })), - content: notif.text, - imageUrl: notif.image + pid: recipient.pid, + user: user ? mapShallowUser(user) : undefined, + hasRead: recipient.hasRead, + updatedAt: notif.updatedAt, + notif: data }; } diff --git a/apps/miiverse-api/src/services/internal/contract/post.ts b/apps/miiverse-api/src/services/internal/contract/post.ts index 1b4c207f..e209aac7 100644 --- a/apps/miiverse-api/src/services/internal/contract/post.ts +++ b/apps/miiverse-api/src/services/internal/contract/post.ts @@ -1,118 +1,151 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowCommunity, shallowCommunitySchema } from '@/services/internal/contract/community'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; import type { IPost } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const postSchema = asOpenapi('Post', z.object({ id: z.string(), - title_id: z.string().optional(), // number as string - screen_name: z.string(), - body: z.string(), - app_data: z.string().optional(), // nintendo base64 - - painting: z.string().optional(), // base64 or '', undef for PMs - painting_img: z.string().optional(), // URL frag (leading /) or '', undef for PMs - painting_big: z.string().optional(), // URL frag (leading /) or '', undef for PMs - screenshot: z.string().optional(), // URL frag (leading /) or '', undef for PMs - screenshot_big: z.string().optional(), // URL frag (leading /) or '', undef for PMs/old posts - screenshot_thumb: z.string().optional(), // URL frag (leading /) or '', undef for PMs/old posts - screenshot_length: z.number().optional(), - screenshot_aspect: z.string().optional(), // '4:3' '5:3' '16:9' - - search_key: z.array(z.string()).optional(), - topic_tag: z.string().optional(), - - community_id: z.string(), // number - created_at: z.string(), // ISO Z - feeling_id: z.number().optional(), - - is_autopost: z.boolean(), - is_community_private_autopost: z.boolean(), - is_spoiler: z.boolean(), - is_app_jumpable: z.boolean(), - - empathy_count: z.number(), - country_id: z.number(), - language_id: z.number(), - - mii: z.string(), // nintendo base64 - mii_face_url: z.string(), // full URL (cdn., r2-cdn.) - - pid: z.number(), - platform_id: z.number().optional(), - region_id: z.number().optional(), - parent: z.string().nullable(), - - reply_count: z.number(), - verified: z.boolean(), - - message_to_pid: z.string().nullable(), - - removed: z.boolean(), - removed_by: z.number().optional(), - removed_at: z.string().optional(), // ISO Z - removed_reason: z.string().optional(), - - yeahs: z.array(z.number()) + createdAt: z.date(), + parentId: z.string().nullable(), + dmTo: z.number().nullable(), + community: shallowCommunitySchema.optional(), + author: z.object({ + miiName: z.string(), + pid: z.number(), + verified: z.boolean() + }), + mii: z.object({ + data: z.string(), + imageUrl: z.string() + }), + + feelingId: z.number().nullable(), + body: z.string().nullable(), + painting: z.object({ + data: z.string(), + imageUrl: z.string(), + imageUrlBig: z.string() + }).nullable(), + screenshot: z.object({ + imageUrl: z.string(), + imageUrlBig: z.string(), + imageUrlThumbnail: z.string(), + length: z.number(), + aspectRatio: z.enum(['4:3', '5:3', '16:9']) + }).nullable(), + + stats: z.object({ + empathyCount: z.number(), + replyCount: z.number() + }), + yeahsBy: z.array(z.object({ + pid: z.number() + })), + + searchKey: z.array(z.string()), + topicTag: z.string().nullable(), + + isAutopost: z.boolean(), + isCommunityPrivateAutopost: z.boolean(), + isSpoiler: z.boolean(), + isAppJumpable: z.boolean(), + + countryId: z.number(), + languageId: z.number(), + platformId: z.number().nullable(), + regionId: z.number().nullable(), + titleId: z.string().nullable(), + appData: z.string().nullable(), + + moderation: z.object({ + removed: z.object({ + removedBy: shallowUserSchema.nullable(), + removedAt: z.date(), + reason: z.string() + }).nullable() + }).nullable() })); export type PostDto = z.infer; -/** - * Maps a Post from the databse to the frontend contract post type. - * Doesn't do much for now, but as the database changes, this abstraction will carry more. - * @param post Database post type - * @returns API/frontend post type - */ -export function mapPost(post: IPost): PostDto { +export function mapPost(post: IPost, comm: HydratedCommunityDocument | null): PostDto { return { id: post.id, - title_id: post.title_id, - screen_name: post.screen_name, - body: post.body, - app_data: post.app_data, - - painting: post.painting, - painting_img: post.painting_img, - painting_big: post.painting_big, - screenshot: post.screenshot, - screenshot_big: post.screenshot_big, - screenshot_thumb: post.screenshot_thumb, - screenshot_length: post.screenshot_length, - screenshot_aspect: post.screenshot_aspect, - - search_key: post.search_key, - topic_tag: post.topic_tag, - - community_id: post.community_id, - created_at: post.created_at.toISOString(), - feeling_id: post.feeling_id, - - is_autopost: !!post.is_autopost, - is_community_private_autopost: !!post.is_community_private_autopost, - is_spoiler: !!post.is_spoiler, - is_app_jumpable: !!post.is_app_jumpable, - - empathy_count: post.empathy_count, - country_id: post.country_id, - language_id: post.language_id, - - mii: post.mii, - mii_face_url: post.mii_face_url, - - pid: post.pid, - platform_id: post.platform_id, - region_id: post.region_id, - parent: post.parent ?? null, - - reply_count: post.reply_count, - verified: post.verified, - - message_to_pid: post.message_to_pid, - removed: post.removed, - removed_by: post.removed_by, - removed_at: post.removed_at?.toISOString(), - removed_reason: post.removed_reason, - - yeahs: post.yeahs + createdAt: post.created_at, + parentId: post.parent ?? null, + dmTo: post.message_to_pid ? Number(post.message_to_pid) : null, + community: comm ? mapShallowCommunity(comm) : undefined, + author: { + miiName: post.screen_name, + pid: post.pid, + verified: post.verified + }, + mii: { + data: post.mii, + imageUrl: post.mii_face_url + }, + + feelingId: post.feeling_id ?? null, + body: post.body ? post.body : null, + painting: post.painting + ? { + data: post.painting, + imageUrl: post.painting_img ?? '', + imageUrlBig: post.painting_big ?? '' + } + : null, + screenshot: post.screenshot + ? { + imageUrl: post.screenshot, + imageUrlBig: post.screenshot_big ?? '', + imageUrlThumbnail: post.screenshot_thumb ?? '', + length: post.screenshot_length ?? 0, + aspectRatio: post.screenshot_aspect as any + } + : null, + + stats: { + empathyCount: post.empathy_count, + replyCount: post.reply_count + }, + yeahsBy: post.yeahs.map(pid => ({ + pid + })), + + searchKey: post.search_key ?? [], + topicTag: post.topic_tag ?? null, + + isAutopost: !!post.is_autopost, + isCommunityPrivateAutopost: !!post.is_community_private_autopost, + isSpoiler: !!post.is_spoiler, + isAppJumpable: !!post.is_app_jumpable, + + countryId: post.country_id, + languageId: post.language_id, + platformId: post.platform_id ?? null, + regionId: post.region_id ?? null, + titleId: post.title_id ?? null, + appData: post.app_data ?? null, + + moderation: null + }; +} + +export function mapPostWithModeration(post: IPost, comm: HydratedCommunityDocument | null, remover: HydratedSettingsDocument | null): PostDto { + return { + ...mapPost(post, comm), + + moderation: { + removed: post.removed + ? { + removedBy: remover ? mapShallowUser(remover) : null, + removedAt: post.removed_at ?? new Date(), + reason: post.removed_reason ?? '' + } + : null + } }; } diff --git a/apps/miiverse-api/src/services/internal/contract/self.ts b/apps/miiverse-api/src/services/internal/contract/self.ts index 8bd9385a..62dfd907 100644 --- a/apps/miiverse-api/src/services/internal/contract/self.ts +++ b/apps/miiverse-api/src/services/internal/contract/self.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { asOpenapi } from '@/services/internal/builder/openapi'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; +import type { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; import type { AccountData } from '@/types/common/account-data'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const banCodes = z.enum(['network_ban', 'temp_ban', 'perma_ban']).openapi('BanCodeEnum'); @@ -44,6 +47,14 @@ export const selfNotificationCountSchema = z.object({ export type SelfNotificationCountDto = z.infer; +export const selfFriendRequestSchema = z.object({ + sentAt: z.date(), + sender: shallowUserSchema, + message: z.string() +}).openapi('SelfFriendRequest'); + +export type SelfFriendRequestDto = z.infer; + const baseSelf: SelfDto = { pid: 0, username: '', @@ -113,3 +124,11 @@ export function mapSelfNotificationCount(unreadNotifications: number): SelfNotif unreadNotifications }; } + +export function mapSelfFriendRequest(req: FriendRequest, user: HydratedSettingsDocument): SelfFriendRequestDto { + return { + sentAt: new Date(Number(req.sent) * 1000), + sender: mapShallowUser(user), + message: req.message + }; +} diff --git a/apps/miiverse-api/src/services/internal/contract/user.ts b/apps/miiverse-api/src/services/internal/contract/user.ts index 117e966a..6d3b0535 100644 --- a/apps/miiverse-api/src/services/internal/contract/user.ts +++ b/apps/miiverse-api/src/services/internal/contract/user.ts @@ -15,11 +15,11 @@ export const userBadgeSchema = z.enum([ export type UserBadgeEnum = z.infer; -export const shallowUserSchema = z.object({ +export const shallowUserSchema = asOpenapi('ShallowUser', z.object({ pid: z.number(), miiName: z.string(), accountStatus: z.number() -}).openapi('ShallowUser'); +})); export type ShallowUserDto = z.infer; diff --git a/apps/miiverse-api/src/services/internal/contract/userSettings.ts b/apps/miiverse-api/src/services/internal/contract/userSettings.ts new file mode 100644 index 00000000..c349fd85 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/userSettings.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; + +export const profileVisibilitySchema = z.enum(['public', 'users_only']).openapi('ProfileVisibilityEnum'); +export type ProfileVisibilityEnum = z.infer; + +export const userSettingsSchema = z.object({ + pid: z.number(), + profileVisibility: profileVisibilitySchema, + countryVisible: z.boolean(), + birthdayVisible: z.boolean(), + gameSkillVisible: z.boolean(), + comment: z.string().nullable() +}).openapi('UserSettings'); +export type UserSettingsDto = z.infer; + +export function mapUserSettings(settings: HydratedSettingsDocument): UserSettingsDto { + return { + pid: settings.pid, + profileVisibility: (settings.profile_visibility as ProfileVisibilityEnum | null) ?? 'public', + birthdayVisible: settings.birthday_visibility, + countryVisible: settings.country_visibility, + gameSkillVisible: settings.game_skill_visibility, + comment: settings.profile_comment_visibility ? settings.profile_comment ?? '' : null + }; +} diff --git a/apps/miiverse-api/src/services/internal/errors.ts b/apps/miiverse-api/src/services/internal/errors.ts index 12b073a3..db6dd619 100644 --- a/apps/miiverse-api/src/services/internal/errors.ts +++ b/apps/miiverse-api/src/services/internal/errors.ts @@ -33,7 +33,8 @@ const errorCodes = { auth_account_network_banned: 403, auth_onboarding_incomplete: 403, user_deleted: 404, - user_banned: 404 + user_banned: 404, + automod_prevented: 400 } as const; const errorCodeKeys = Object.keys(errorCodes) as [keyof typeof errorCodes, ...Array]; diff --git a/apps/miiverse-api/src/services/internal/index.ts b/apps/miiverse-api/src/services/internal/index.ts index 718b49c0..e4f4e790 100644 --- a/apps/miiverse-api/src/services/internal/index.ts +++ b/apps/miiverse-api/src/services/internal/index.ts @@ -11,21 +11,29 @@ import { adminReportsRouter } from '@/services/internal/routes/admin/adminReport import { adminUsersRouter } from '@/services/internal/routes/admin/adminUsers'; import { notificationsRouter } from '@/services/internal/routes/notification'; import { adminAuditLogs } from '@/services/internal/routes/admin/adminAuditLogs'; +import { adminStatsRouter } from '@/services/internal/routes/admin/adminStats'; +import { discoveryRouter } from '@/services/internal/routes/discovery'; import { adminAutomodRouter } from '@/services/internal/routes/admin/adminAutomod'; +import { onboardingRouter } from '@/services/internal/routes/onboarding'; +import { userSettingsRouter } from '@/services/internal/routes/userSettings'; export const internalApiRouter = express.Router(); +internalApiRouter.use('/api/v1', discoveryRouter.toRouter()); internalApiRouter.use('/api/v1', postsRouter.toRouter()); internalApiRouter.use('/api/v1', communitiesRouter.toRouter()); internalApiRouter.use('/api/v1', activityFeedsRouter.toRouter()); internalApiRouter.use('/api/v1', communityPostsRouter.toRouter()); internalApiRouter.use('/api/v1', userPostsRouter.toRouter()); internalApiRouter.use('/api/v1', userProfileRouter.toRouter()); +internalApiRouter.use('/api/v1', userSettingsRouter.toRouter()); internalApiRouter.use('/api/v1', selfRouter.toRouter()); internalApiRouter.use('/api/v1', notificationsRouter.toRouter()); +internalApiRouter.use('/api/v1', onboardingRouter.toRouter()); internalApiRouter.use('/api/v1', adminCommunitiesRouter.toRouter()); internalApiRouter.use('/api/v1', adminReportsRouter.toRouter()); internalApiRouter.use('/api/v1', adminUsersRouter.toRouter()); internalApiRouter.use('/api/v1', adminAuditLogs.toRouter()); +internalApiRouter.use('/api/v1', adminStatsRouter.toRouter()); internalApiRouter.use('/api/v1', adminAutomodRouter.toRouter()); diff --git a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts index 5666cba7..d4205054 100644 --- a/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts +++ b/apps/miiverse-api/src/services/internal/routes/activityFeeds.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Community } from '@/models/community'; +import { Settings } from '@/models/settings'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -30,7 +32,22 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); @@ -64,7 +81,22 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); @@ -99,6 +131,21 @@ activityFeedsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts index 7786016b..010a3829 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminAutomod.ts @@ -10,6 +10,7 @@ import { errors } from '@/services/internal/errors'; import { mapResult, resultSchema } from '@/services/internal/contract/result'; import { automodLogSchema, mapAutomodLog } from '@/services/internal/contract/admin/automodLog'; import { automodAction, AutomodLog } from '@/models/automodLog'; +import { Settings } from '@/models/settings'; import type { RootFilterQuery } from 'mongoose'; export const adminAutomodRouter = createInternalApiRouter(); @@ -164,6 +165,19 @@ adminAutomodRouter.get({ } }); - return mapPage(total, logs.map(log => mapAutomodLog(log, rules.find(v => v.id === log.rule_id) ?? null))); + const userIds = logs.map(v => v.author); + const users = await Settings.find({ + pid: { + $in: userIds + } + }); + + const mappedLogs = logs.map((log) => { + const rule = rules.find(v => v.id === log.rule_id) ?? null; + const user = users.find(v => v.pid === log.author) ?? null; + + return mapAutomodLog(log, user, rule); + }); + return mapPage(total, mappedLogs); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts index 480466a2..53c6ee2b 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminReports.ts @@ -11,6 +11,8 @@ import { createNewPostDeletionNotification } from '@/services/internal/utils/not import { deleteOptional } from '@/services/internal/utils'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; +import { Community } from '@/models/community'; +import { Settings } from '@/models/settings'; export const adminReportsRouter = createInternalApiRouter(); @@ -45,9 +47,21 @@ adminReportsRouter.get({ const posts = await Post.find( { id: { $in: postIds } } ); - const postMap = new Map(posts.map(p => [p.id, p])); - let reports = rawReports.map(v => mapReport(v, postMap.get(v.post_id) ?? null)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const relatedUserIds = [ + ...rawReports.flatMap(v => [v.reported_by, v.resolved_by]), + ...posts.map(v => v.removed_by) + ].filter((v): v is number => !!v); + const users = await Settings.find({ pid: { $in: relatedUserIds } }); + + let reports = rawReports.map((report) => { + const post = posts.find(v => v.id === report.post_id) ?? null; + const community = post ? communities.find(v => v.olive_community_id === post.community_id) ?? null : null; + return mapReport(report, users, post, community); + }); // Reports can be resolved by the original post being removed, this is not set in the DB // This is why pagination is not possible when filtering for resolved states @@ -73,7 +87,7 @@ adminReportsRouter.post({ }), response: resultSchema }, - async handler({ params, body, auth }) { + async handler({ db, params, body, auth }) { const account = auth!; const report = await Report.findOne({ _id: params.id }); @@ -105,7 +119,7 @@ adminReportsRouter.post({ note: reason } }); - await createNewPostDeletionNotification({ + await createNewPostDeletionNotification(db, { postAuthor: post.pid, post: post, reason diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts new file mode 100644 index 00000000..d40427ff --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminStats.ts @@ -0,0 +1,26 @@ +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { guards } from '@/services/internal/middleware/guards'; +import { adminStatsSchema, mapAdminStats } from '@/services/internal/contract/admin/adminStats'; +import { getPostMetrics, getUserMetrics } from '@/metrics'; + +export const adminStatsRouter = createInternalApiRouter(); + +adminStatsRouter.get({ + path: '/admin/stats', + name: 'admin.getStats', + guard: guards.moderator, + schema: { + response: adminStatsSchema + }, + async handler() { + const userMetrics = await getUserMetrics(); + const postMetrics = await getPostMetrics(); + + return mapAdminStats({ + dailyPosts: postMetrics.dailyPosts, + totalPosts: postMetrics.totalPosts, + onlineUsers: userMetrics.currentOnlineUsers, + totalUsers: userMetrics.totalUsers + }); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts b/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts index 3bd468d1..0cd140f4 100644 --- a/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts +++ b/apps/miiverse-api/src/services/internal/routes/admin/adminUsers.ts @@ -116,7 +116,7 @@ adminUsersRouter.patch({ }), response: moderationProfileSchema }, - async handler({ params, body, auth }) { + async handler({ params, db, body, auth }) { const account = auth!; const oldSettings = await Settings.findOne({ pid: params.id }); if (!oldSettings) { @@ -141,7 +141,7 @@ adminUsersRouter.patch({ const accountStatusChanged = oldSettings.account_status !== settings.account_status; if (accountStatusChanged && settings.account_status === 1) { - await createNewLimitedPostingNotification({ + await createNewLimitedPostingNotification(db, { pid: settings.pid, banLiftDate: settings.ban_lift_date ?? null, reason: settings.ban_reason ?? null diff --git a/apps/miiverse-api/src/services/internal/routes/communities.ts b/apps/miiverse-api/src/services/internal/routes/communities.ts index b2d27272..dae79aaa 100644 --- a/apps/miiverse-api/src/services/internal/routes/communities.ts +++ b/apps/miiverse-api/src/services/internal/routes/communities.ts @@ -10,6 +10,7 @@ import { categoryToCommunityTypes, communityCategory, communitySchema, community import { errors } from '@/services/internal/errors'; import { Post } from '@/models/post'; import { listDtoSchema, mapList } from '@/services/internal/contract/list'; +import { followSchema, mapFollowCommunity } from '@/services/internal/contract/follow'; import type { FilterQuery, RootFilterQuery } from 'mongoose'; import type { ICommunity, HydratedCommunityDocument } from '@/types/mongoose/community'; @@ -173,6 +174,103 @@ communitiesRouter.get({ } }); +communitiesRouter.post({ + path: '/communities/:id/followers/@me', + name: 'communities.follow', + guard: guards.user, + schema: { + params: z.object({ + id: z.string() + }), + response: followSchema + }, + async handler({ params, auth }) { + const communityId = params.id; + + const typesToFilter: RootFilterQuery = auth?.moderator + ? {} + : { + type: { + $ne: COMMUNITY_TYPE.Private + } + }; + + const community = await Community.findOne(deleteOptional({ + olive_community_id: communityId, + ...typesToFilter + })); + if (!community) { + throw errors.for('not_found'); + } + + const currentUser = auth!; + if (!currentUser.content) { + throw errors.for('not_found'); + } + + const isFollowing = currentUser.content.followed_communities.includes(communityId); + if (isFollowing) { + return mapFollowCommunity('follow', community); + } + + currentUser.content.followed_communities.push(communityId); + await currentUser.content.save(); + community.followers += 1; + await community.save(); + + return mapFollowCommunity('follow', community); + } +}); + +communitiesRouter.delete({ + path: '/communities/:id/followers/@me', + name: 'communities.unfollow', + guard: guards.user, + schema: { + params: z.object({ + id: z.string() + }), + response: followSchema + }, + async handler({ params, auth }) { + const communityId = params.id; + + const typesToFilter: RootFilterQuery = auth?.moderator + ? {} + : { + type: { + $ne: COMMUNITY_TYPE.Private + } + }; + + const community = await Community.findOne(deleteOptional({ + olive_community_id: communityId, + ...typesToFilter + })); + if (!community) { + throw errors.for('not_found'); + } + + const currentUser = auth!; + if (!currentUser.content) { + throw errors.for('not_found'); + } + + const isFollowing = currentUser.content.followed_communities.includes(communityId); + if (!isFollowing) { + return mapFollowCommunity('unfollow', community); + } + + const newFollowingList = currentUser.content.followed_communities.filter(v => v !== communityId); + currentUser.content.followed_communities = newFollowingList; + await currentUser.content.save(); + community.followers -= 1; + await community.save(); + + return mapFollowCommunity('unfollow', community); + } +}); + async function calculateMostPopularCommunities(rangeMs: number): Promise { const now = new Date(); const last24Hours = new Date(now.getTime() - rangeMs); diff --git a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts index 2a9f3e17..dc0522f8 100644 --- a/apps/miiverse-api/src/services/internal/routes/communityPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/communityPosts.ts @@ -2,9 +2,15 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { feedPageDtoSchema, mapFeedPage, pageControlSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Community } from '@/models/community'; +import { createNewPost, postCreateSchema } from '@/services/internal/utils/posts'; +import { errors } from '@/services/internal/errors'; +import { isPostingAllowed } from '@/services/internal/utils/communities'; +import { mapSelf } from '@/services/internal/contract/self'; +import { Settings } from '@/models/settings'; export const communityPostsRouter = createInternalApiRouter(); @@ -31,7 +37,22 @@ communityPostsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); } }); @@ -57,6 +78,61 @@ communityPostsRouter.get({ .skip(query.offset) .limit(query.limit); - return mapFeedPage(posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapFeedPage(mappedPosts); + } +}); + +communityPostsRouter.post({ + path: '/communities/:id/posts', + name: 'communities.createPost', + guard: guards.user, + schema: { + params: z.object({ + id: z.string() + }), + body: postCreateSchema, + response: postSchema + }, + async handler({ body, params, auth }) { + const account = auth!; + + const community = await Community.findOne({ olive_community_id: params.id }); + if (!community) { + throw errors.for('not_found'); + } + + const self = mapSelf(account); + if (!isPostingAllowed(community, self, null)) { + throw errors.for('forbidden'); + } + + const newPost = await createNewPost({ + author: { + pid: account.pnid.pid, + miiData: account.pnid.mii?.data ?? '', + screenName: account.settings?.screen_name ?? '', + verified: self.permissions.moderator + }, + body, + community, + parentPost: null + }); + + return mapPost(newPost, community); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/discovery.ts b/apps/miiverse-api/src/services/internal/routes/discovery.ts new file mode 100644 index 00000000..64e4ec4e --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/discovery.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { guards } from '@/services/internal/middleware/guards'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Endpoint } from '@/models/endpoint'; +import { discoverySchema, mapDiscovery } from '@/services/internal/contract/discovery'; + +export const discoveryRouter = createInternalApiRouter(); + +discoveryRouter.get({ + path: '/discovery/:environment', + name: 'discovery.get', + description: 'Get information about this current server', + guard: guards.guest, + schema: { + params: z.object({ + environment: z.string() + }), + response: discoverySchema + }, + async handler({ params }) { + const discovery = await Endpoint.findOne({ server_access_level: params.environment }); + return mapDiscovery(discovery); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/notification.ts b/apps/miiverse-api/src/services/internal/routes/notification.ts index afb0b300..5567063f 100644 --- a/apps/miiverse-api/src/services/internal/routes/notification.ts +++ b/apps/miiverse-api/src/services/internal/routes/notification.ts @@ -2,11 +2,10 @@ import { z } from 'zod'; import { guards } from '@/services/internal/middleware/guards'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; -import { deleteOptional } from '@/services/internal/utils'; -import { Notification } from '@/models/notification'; import { mapNotification, notificationSchema } from '@/services/internal/contract/notification'; -import type { RootFilterQuery } from 'mongoose'; -import type { INotification } from '@/types/mongoose/notification'; +import { Settings } from '@/models/settings'; +import type { FollowNotificationContent } from '@/services/internal/utils/notifications'; +import type { NotificationRecipientWhereInput } from '@/prisma/models'; export const notificationsRouter = createInternalApiRouter(); @@ -21,32 +20,53 @@ notificationsRouter.get({ }).extend(pageControlSchema()), response: pageDtoSchema(notificationSchema) }, - async handler({ query, auth }) { + async handler({ query, db, auth }) { const account = auth!; - const dbQuery: RootFilterQuery = deleteOptional({ - read: query.read, + const dbQuery: NotificationRecipientWhereInput = { + hasRead: query.read, pid: account.pnid.pid + }; + const notifications = await db.notificationRecipient.findMany({ + where: dbQuery, + take: query.limit, + skip: query.offset, + orderBy: { + notification: { + updatedAt: 'desc' + } + }, + include: { + notification: true + } + }); + const total = await db.notificationRecipient.count({ + where: dbQuery }); - const notifications = await Notification - .find(dbQuery) - .sort({ lastUpdated: -1 }) - .limit(query.limit) - .skip(query.offset); - const total = await Notification.countDocuments(dbQuery); + + const relatedUserIds = notifications.reduce((acc, v) => { + acc.push(v.pid); + if (v.notification.type === 'Follow') { + const content = v.notification.content as FollowNotificationContent; + acc.push(...content.users.map(u => u.pid)); + } + return acc; + }, []); + const users = await Settings.find({ pid: { $in: relatedUserIds } }); if (query.markAsRead) { - await Notification.updateMany({ - _id: { - $in: notifications.map(v => v._id) - } - }, { - $set: { - read: true + await db.notificationRecipient.updateMany({ + where: { + id: { + in: notifications.map(v => v.id) + } + }, + data: { + hasRead: true } }); } - return mapPage(total, notifications.map(v => mapNotification(v))); + return mapPage(total, notifications.map(v => mapNotification(v, v.notification, users))); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/onboarding.ts b/apps/miiverse-api/src/services/internal/routes/onboarding.ts new file mode 100644 index 00000000..745cf452 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/onboarding.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { guards } from '@/services/internal/middleware/guards'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { pageControlSchema } from '@/services/internal/contract/page'; +import { Settings } from '@/models/settings'; +import { errors } from '@/services/internal/errors'; +import { mapResult, resultSchema } from '@/services/internal/contract/result'; +import { Content } from '@/models/content'; + +export const onboardingRouter = createInternalApiRouter(); + +onboardingRouter.post({ + path: '/onboarding/submit', + name: 'onboarding.submit', + guard: guards.guest, // Needs auth, but normal guards check for onboarding + schema: { + body: z.object({ + experienceId: z.number(), + receiveNotifications: z.boolean() + }).extend(pageControlSchema()), + response: resultSchema + }, + async handler({ body, auth }) { + if (!auth) { + // Needs an auth token + throw errors.for('requires_auth'); + } + + if (auth.settings && auth.content) { + // Already completed the full onboarding process + return mapResult('success'); + } + + const name = auth.pnid.mii?.name ?? 'Default'; + if (!auth.settings) { + await Settings.create({ + pid: auth.pnid.pid, + screen_name: name, + game_skill: body.experienceId, + receive_notifications: body.receiveNotifications + }); + } + if (!auth.content) { + await Content.create({ + pid: auth.pnid.pid + }); + } + + return mapResult('success'); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/posts.ts b/apps/miiverse-api/src/services/internal/routes/posts.ts index 7155e9a4..b82cfda7 100644 --- a/apps/miiverse-api/src/services/internal/routes/posts.ts +++ b/apps/miiverse-api/src/services/internal/routes/posts.ts @@ -3,7 +3,7 @@ import { Post } from '@/models/post'; import { errors } from '@/services/internal/errors'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; import { mapResult, resultSchema } from '@/services/internal/contract/result'; import { empathyActionSchema, empathySchema, mapEmpathy } from '@/services/internal/contract/empathy'; @@ -11,10 +11,16 @@ import { postIdObjSchema, postIdSchema } from '@/services/internal/schemas'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { createLogEntry } from '@/services/internal/utils/auditLogs'; +import { Community } from '@/models/community'; +import { Report } from '@/models/report'; +import { createNewPost, postCreateSchema } from '@/services/internal/utils/posts'; +import { isPostingAllowed } from '@/services/internal/utils/communities'; +import { mapSelf } from '@/services/internal/contract/self'; import { Settings } from '@/models/settings'; import { assertCanAccessUser, canAccessUser } from '@/services/internal/utils/user'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; export const postsRouter = createInternalApiRouter(); @@ -70,7 +76,22 @@ postsRouter.get({ .limit(query.limit); const total = await Post.countDocuments(dbQuery); - return mapPage(total, posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapPage(total, mappedPosts); } }); @@ -92,6 +113,12 @@ postsRouter.get({ if (!post) { throw errors.for('not_found'); } + const community = await Community.findOne({ olive_community_id: post.community_id }); + + let remover: HydratedSettingsDocument | null = null; + if (post.removed_by) { + remover = await Settings.findOne({ pid: post.removed_by }); + } const poster = await Settings.findOne({ pid: post.pid }); if (!poster) { @@ -100,7 +127,10 @@ postsRouter.get({ // Throws if user isn't visible for some reason assertCanAccessUser(auth, poster); - return mapPost(post); + if (auth?.moderator) { + return mapPostWithModeration(post, community, remover); + } + return mapPost(post, community); } }); @@ -212,3 +242,131 @@ postsRouter.post({ return mapEmpathy(body.action, post); } }); + +postsRouter.post({ + path: '/posts/:post_id/report', + name: 'posts.report', + guard: guards.user, + schema: { + params: postIdObjSchema, + body: z.object({ + reasonId: z.number(), + message: z.string() + }), + response: resultSchema + }, + async handler({ body, params, auth }) { + // guards.user makes this safe + const account = auth!; + const pid = account.pnid.pid; + + const post = await Post.findOne({ + id: params.post_id, + message_to_pid: null, // messages aren't really posts + removed: false + }); + if (!post) { + throw errors.for('not_found'); + } + + const duplicateReport = await Report.findOne({ + reported_by: pid, + post_id: post.id + }); + if (duplicateReport) { + return mapResult('success'); // Silently reject duplicate reports + } + + await Report.create({ + pid: post.pid, + reported_by: pid, + post_id: post.id, + reason: body.reasonId, + message: body.message + }); + + return mapResult('success'); + } +}); + +postsRouter.post({ + path: '/posts/:post_id/edit/spoiler', + name: 'posts.changeSpoiler', + description: 'Change spoiler for post', + guard: guards.moderator, + schema: { + params: postIdObjSchema, + body: z.object({ + is_spoiler: z.boolean() + }), + response: resultSchema + }, + async handler({ params, body, auth }) { + const post = await Post.findOne({ + id: params.post_id, + message_to_pid: null, // messages aren't really posts + ...filterRemovedPosts(auth) + }); + if (!post) { + throw errors.for('not_found'); + } + + await Post.findOneAndUpdate({ + _id: post._id + }, { + $set: { + is_spoiler: body.is_spoiler + } + + }); + + return mapResult('success'); + } +}); + +postsRouter.post({ + path: '/posts/:post_id/replies', + name: 'posts.reply', + guard: guards.user, + schema: { + params: postIdObjSchema, + body: postCreateSchema, + response: postSchema + }, + async handler({ body, params, auth }) { + const account = auth!; + + const parentPost = await Post.findOne({ + id: params.post_id, + message_to_pid: null, + removed: false + }); + if (!parentPost) { + throw errors.for('not_found'); + } + + const community = await Community.findOne({ olive_community_id: parentPost.community_id }); + if (!community) { + throw errors.for('not_found'); + } + + const self = mapSelf(account); + if (!isPostingAllowed(community, self, parentPost)) { + throw errors.for('forbidden'); + } + + const newPost = await createNewPost({ + author: { + pid: account.pnid.pid, + miiData: account.pnid.mii?.data ?? '', + screenName: account.settings?.screen_name ?? '', + verified: self.permissions.moderator + }, + body, + community, + parentPost + }); + + return mapPost(newPost, community); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts index 55af71ee..fc1fdcef 100644 --- a/apps/miiverse-api/src/services/internal/routes/self.ts +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -1,9 +1,14 @@ +import { z } from 'zod'; import { guards } from '@/services/internal/middleware/guards'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { errors } from '@/services/internal/errors'; -import { mapBannedSelf, mapSelf, mapSelfNotificationCount, selfNotificationCountSchema, selfSchema } from '@/services/internal/contract/self'; +import { mapBannedSelf, mapSelf, mapSelfFriendRequest, mapSelfNotificationCount, selfFriendRequestSchema, selfNotificationCountSchema, selfSchema } from '@/services/internal/contract/self'; import { Settings } from '@/models/settings'; -import { Notification } from '@/models/notification'; +import { getDb } from '@/database'; +import { getUserFriendRequestsIncoming } from '@/util'; +import { Post } from '@/models/post'; +import { Content } from '@/models/content'; +import type { SelfFriendRequestDto } from '@/services/internal/contract/self'; export const selfRouter = createInternalApiRouter(); @@ -76,11 +81,73 @@ selfRouter.get({ async handler({ auth }) { const account = auth!; - const notificationCount = await Notification.countDocuments({ - pid: account.pnid.pid, - read: false + const notificationCount = await getDb().notificationRecipient.count({ + where: { + pid: account.pnid.pid, + hasRead: false + } }); return mapSelfNotificationCount(notificationCount); } }); + +selfRouter.get({ + path: '/self/friend-requests', + name: 'self.getFriendRequests', + description: 'Get friend requests for current user', + guard: guards.user, + schema: { + response: z.array(selfFriendRequestSchema) + }, + async handler({ auth }) { + const account = auth!; + + const now = new Date(); + const allRequests = (await getUserFriendRequestsIncoming(account.pnid.pid)).reverse(); + const validRequests = allRequests.filter(request => new Date(Number(request.expires) * 1000) > new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000)); + + const senders = validRequests.map(v => v.sender); + const relatedUsers = await Settings.find({ pid: { $in: senders } }); + + return validRequests.reduce((acc, req) => { + const user = relatedUsers.find(v => v.pid === req.sender); + if (user) { + acc.push(mapSelfFriendRequest(req, user)); + } + + return acc; + }, []); + } +}); + +selfRouter.post({ + path: '/self/export', + name: 'self.export', + description: 'Do a GDPR export for current user', + guard: guards.user, + schema: { + response: z.any() + }, + async handler({ auth }) { + const account = auth!; + const rawPosts = await Post.find({ pid: account.pnid.pid }); + const userSettings = await Settings.findOne({ pid: account.pnid.pid }); + const userContent = await Content.findOne({ pid: account.pnid.pid }); + + // Clean non-user data + if (userSettings) { + userSettings.banned_by = null; + } + const postsJson = rawPosts.map(post => ({ + ...post.toJSON(), + removed_by: null + })); + + return { + user_content: userContent, + user_settings: userSettings, + posts: postsJson + }; + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/userPosts.ts b/apps/miiverse-api/src/services/internal/routes/userPosts.ts index 94d0b407..6b4e4faa 100644 --- a/apps/miiverse-api/src/services/internal/routes/userPosts.ts +++ b/apps/miiverse-api/src/services/internal/routes/userPosts.ts @@ -2,12 +2,13 @@ import { z } from 'zod'; import { Post } from '@/models/post'; import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; import { guards } from '@/services/internal/middleware/guards'; -import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPost, mapPostWithModeration, postSchema } from '@/services/internal/contract/post'; import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; import { createInternalApiRouter } from '@/services/internal/builder/router'; import { canAccessUser } from '@/services/internal/utils/user'; import { getPostTypeFilter, postTypeFilter, standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; import { Settings } from '@/models/settings'; +import { Community } from '@/models/community'; import type { FilterQuery } from 'mongoose'; import type { IPost } from '@/types/mongoose/post'; @@ -54,6 +55,21 @@ userPostsRouter.get({ .limit(query.limit); const total = await Post.countDocuments(dbQuery); - return mapPage(total, posts.map(mapPost)); + const communityIds = posts.map(v => v.community_id); + const communities = await Community.find({ olive_community_id: { $in: communityIds } }); + + const userIds = posts.flatMap(v => v.removed_by).filter(v => !!v); + const users = await Settings.find({ pid: { $in: userIds } }); + + const mappedPosts = posts.map((p) => { + const comm = communities.find(v => v.olive_community_id === p.community_id) ?? null; + const remover = p.removed_by ? users.find(v => v.pid === p.removed_by) ?? null : null; + + if (auth?.moderator) { + return mapPostWithModeration(p, comm, remover); + } + return mapPost(p, comm); + }); + return mapPage(total, mappedPosts); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/userProfile.ts b/apps/miiverse-api/src/services/internal/routes/userProfile.ts index 8d632257..2a56dd70 100644 --- a/apps/miiverse-api/src/services/internal/routes/userProfile.ts +++ b/apps/miiverse-api/src/services/internal/routes/userProfile.ts @@ -238,7 +238,7 @@ userProfileRouter.post({ }), response: followSchema }, - async handler({ params, auth }) { + async handler({ params, db, auth }) { const targetUserPid = params.id; const targetUser = await Settings.findOne({ pid: targetUserPid }); const targetUserContent = await Content.findOne({ pid: targetUserPid }); @@ -265,7 +265,7 @@ userProfileRouter.post({ await targetUserContent.save(); await currentUser.content.save(); - await createNewFollowNotification({ currentUser: currentUserPid, userToFollow: targetUserPid }); + await createNewFollowNotification(db, { currentUser: currentUserPid, userToFollow: targetUserPid }); return mapFollowUser('follow', targetUserContent); } }); diff --git a/apps/miiverse-api/src/services/internal/routes/userSettings.ts b/apps/miiverse-api/src/services/internal/routes/userSettings.ts new file mode 100644 index 00000000..c11cf902 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/userSettings.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { guards } from '@/services/internal/middleware/guards'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { errors } from '@/services/internal/errors'; +import { mapResult, resultSchema } from '@/services/internal/contract/result'; +import { mapUserSettings, profileVisibilitySchema, userSettingsSchema } from '@/services/internal/contract/userSettings'; + +export const userSettingsRouter = createInternalApiRouter(); + +userSettingsRouter.get({ + path: '/users/@me/settings', + name: 'users.me.settings.get', + description: 'Get user settings', + guard: guards.user, + allowNotFound: true, + schema: { + response: userSettingsSchema + }, + async handler({ auth }) { + const account = auth!; + if (!account.settings) { + throw errors.for('not_found'); + } + + return mapUserSettings(account.settings); + } +}); + +userSettingsRouter.post({ + path: '/users/@me/settings', + name: 'users.me.settings.update', + description: 'Update user settings', + guard: guards.user, + schema: { + body: z.object({ + profileVisibility: profileVisibilitySchema, + countryVisible: z.boolean(), + birthdayVisible: z.boolean(), + gameSkillVisible: z.boolean(), + comment: z.string().nullable() + }), + response: resultSchema + }, + async handler({ body, auth }) { + const account = auth!; + const settings = account.settings; + if (!settings) { + throw errors.for('not_found'); + } + + settings.profile_visibility = body.profileVisibility; + settings.country_visibility = body.countryVisible; + settings.birthday_visibility = body.birthdayVisible; + settings.game_skill_visibility = body.gameSkillVisible; + + if (body.comment) { + settings.profile_comment_visibility = true; + settings.profile_comment = body.comment; + } else { + settings.profile_comment_visibility = false; + settings.profile_comment = ''; + } + + await settings.save(); + + return mapResult('success'); + } +}); diff --git a/apps/miiverse-api/src/services/internal/utils/communities.ts b/apps/miiverse-api/src/services/internal/utils/communities.ts index c88841b4..c4cbae8c 100644 --- a/apps/miiverse-api/src/services/internal/utils/communities.ts +++ b/apps/miiverse-api/src/services/internal/utils/communities.ts @@ -1,4 +1,7 @@ import { Community } from '@/models/community'; +import type { SelfDto } from '@/services/internal/contract/self'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { HydratedPostDocument } from '@/types/mongoose/post'; export async function generateCommunityId(length?: number | undefined): Promise { let id = crypto.getRandomValues(new Uint32Array(1)).toString().substring(0, length); @@ -26,3 +29,29 @@ export const communityPlatformDisplayMap: Record = { 1: '3DS', 2: 'Both' }; + +export function isPostingAllowed(community: HydratedCommunityDocument, user: SelfDto, parentPost: HydratedPostDocument | null): boolean { + const isReply = !!parentPost; + const isPublicPostableCommunity = community.type === 0 || community.type === 1; + const isOpenCommunity = community.permissions.open; + + const isCommunityAdmin = community.admins?.includes(user.pid) ?? false; + const isUserLimitedFromPosting = !user.permissions.posting; + const hasAccessLevelRequirement = isReply + ? user.permissions.accessLevel >= community.permissions.minimum_new_comment_access_level + : user.permissions.accessLevel >= community.permissions.minimum_new_post_access_level; + + if (isUserLimitedFromPosting) { + return false; + } + + if (isCommunityAdmin) { + return true; // admins can always post (if not limited) + } + + if (!hasAccessLevelRequirement) { + return false; + } + + return isReply ? isOpenCommunity : isPublicPostableCommunity; +} diff --git a/apps/miiverse-api/src/services/internal/utils/notifications.ts b/apps/miiverse-api/src/services/internal/utils/notifications.ts index ef6c4da0..cc98c90f 100644 --- a/apps/miiverse-api/src/services/internal/utils/notifications.ts +++ b/apps/miiverse-api/src/services/internal/utils/notifications.ts @@ -1,5 +1,5 @@ -import { Notification } from '@/models/notification'; -import { humanDate } from '@/services/internal/utils/dates'; +import { genId } from '@/util'; +import type { PrismaClient } from '@/prisma/client'; import type { IPost } from '@/types/mongoose/post'; export type FollowNotificationOptions = { @@ -19,78 +19,157 @@ export type LimitedPostingNotificationOptions = { reason: string | null; }; -export async function createNewFollowNotification(ops: FollowNotificationOptions): Promise { +export type FollowNotificationContent = { + users: { + timestamp: string; // ISO timestamp + pid: number; + }[]; +}; + +export type SystemNotificationContent = { + imagePath: string; + link: string; + text: string; +}; + +export type PostDeletedNotificationContent = { + postId: string; + reason?: string; + postType: 'comment' | 'post'; +}; + +export type LimitedFromPostingNotificationContent = { + reason?: string; + until?: string; // ISO timestamp, +}; + +export async function createNewFollowNotification(db: PrismaClient, ops: FollowNotificationOptions): Promise { const now = new Date(); - const url = `/users/${ops.currentUser}`; - // Same user has followed previously - const existingNotif = await Notification.findOne({ pid: ops.userToFollow, objectID: ops.currentUser }); - if (existingNotif) { - existingNotif.lastUpdated = now; - existingNotif.read = false; - await existingNotif.save(); + // Prevent sending a new notification if the same user has done so recently + const weekInMs = 7 * 24 * 60 * 60 * 1000; + const recentFollowNotifs = await db.notificationRecipient.findMany({ + where: { + pid: ops.userToFollow, + notification: { + type: 'Follow', + updatedAt: { + gte: new Date(now.getTime() - weekInMs) // Get 7 days worth of follow notifications + } + } + }, + include: { + notification: true + } + }); + const followNotifsContent = recentFollowNotifs.map(v => v.notification.content as FollowNotificationContent); + const hasFollowNotifContentForUser = followNotifsContent.some(content => content.users.some(usr => usr.pid === ops.currentUser)); + if (hasFollowNotifContentForUser) { + // Don't send any notification to prevent follow notif spam return; } - // Combine existing follower notification - const last60min = new Date(now.getTime() - 60 * 60 * 1000); - const groupedNotif = await Notification.findOne({ pid: ops.userToFollow, type: 'follow', lastUpdated: { $gte: last60min } }); - if (groupedNotif) { - groupedNotif.users.push({ - user: ops.currentUser, - timestamp: now + // Group follower notifications into batch + const hourInMs = 60 * 60 * 1000; + const recentFollowNotif = await db.notificationRecipient.findFirst({ + where: { + pid: ops.userToFollow, + notification: { + type: 'Follow', + updatedAt: { + gte: new Date(now.getTime() - hourInMs) + } + } + }, + include: { + notification: true + } + }); + if (recentFollowNotif) { + const newContent = recentFollowNotif.notification.content as FollowNotificationContent; + newContent.users.push({ + pid: ops.currentUser, + timestamp: now.toISOString() + }); + await db.notification.update({ + where: { + id: recentFollowNotif.notificationId + }, + data: { + content: newContent, + updatedAt: now + } + }); + await db.notificationRecipient.update({ + where: { + id: recentFollowNotif.id + }, + data: { + hasRead: false + } }); - groupedNotif.lastUpdated = now; - groupedNotif.link = url; - groupedNotif.objectID = ops.currentUser.toString(); - groupedNotif.read = false; - await groupedNotif.save(); return; } // Create new notification - await Notification.create({ - pid: ops.userToFollow, - type: 'follow', + const content: FollowNotificationContent = { users: [{ - user: ops.currentUser, - timestamp: now - }], - link: url, - objectID: ops.currentUser.toString(), - read: false, - lastUpdated: now + pid: ops.currentUser, + timestamp: now.toISOString() + }] + }; + await db.notification.create({ + data: { + id: genId(), + content, + type: 'Follow', + notificationRecipients: { + create: { + id: genId(), + pid: ops.userToFollow + } + } + } }); } -export async function createNewPostDeletionNotification(ops: PostDeletionNotificationOptions): Promise { - const postType = ops.post.parent ? 'comment' : 'post'; - - await Notification.create({ - pid: ops.postAuthor, - type: 'notice', - read: false, - lastUpdated: new Date(), - text: `Your ${postType} "${ops.post.id}" has been removed` + - (ops.reason ? ` for the following reason: "${ops.reason}". ` : '. ') + - `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/).`, - image: '/images/bandwidthalert.png', - link: '/titles/2551084080/new' +export async function createNewPostDeletionNotification(db: PrismaClient, ops: PostDeletionNotificationOptions): Promise { + const content: PostDeletedNotificationContent = { + postId: ops.post.id, + reason: ops.reason, + postType: ops.post.parent ? 'comment' : 'post' + }; + await db.notification.create({ + data: { + id: genId(), + content, + type: 'PostDeleted', + notificationRecipients: { + create: { + id: genId(), + pid: ops.postAuthor + } + } + } }); } -export async function createNewLimitedPostingNotification(ops: LimitedPostingNotificationOptions): Promise { - const firstSentence = ops.banLiftDate ? `You have been Limited from Posting until ${humanDate(ops.banLiftDate)}. ` : `You have been Limited from Posting. `; - - await Notification.create({ - pid: ops.pid, - type: 'notice', - text: firstSentence + - (ops.reason ? `Reason: "${ops.reason}". ` : '') + - `Click this message to view the Juxtaposition Code of Conduct. ` + - `If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/).`, - image: '/images/bandwidthalert.png', - link: '/titles/2551084080/new' +export async function createNewLimitedPostingNotification(db: PrismaClient, ops: LimitedPostingNotificationOptions): Promise { + const content: LimitedFromPostingNotificationContent = { + until: ops.banLiftDate?.toISOString() ?? undefined, + reason: ops.reason ?? undefined + }; + await db.notification.create({ + data: { + id: genId(), + content, + type: 'LimitedFromPosting', + notificationRecipients: { + create: { + id: genId(), + pid: ops.pid + } + } + } }); } diff --git a/apps/miiverse-api/src/services/internal/utils/posts.ts b/apps/miiverse-api/src/services/internal/utils/posts.ts new file mode 100644 index 00000000..52b57456 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/utils/posts.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; +import { uploadPainting, uploadScreenshot } from '@/images'; +import { getShotModeForTitleId } from '@/services/api/routes/posts'; +import { evaluateAutomodRules, getInvalidPostRegex, performAutomodAction } from '@/util'; +import { config } from '@/config'; +import { getDuplicatePosts } from '@/database'; +import { Post } from '@/models/post'; +import { asOpenapi } from '@/services/internal/builder/openapi'; +import { AutomodRule } from '@/models/automodRules'; +import { errors } from '@/services/internal/errors'; +import type { PaintingUrls } from '@/images'; +import type { HydratedPostDocument, IPostInput } from '@/types/mongoose/post'; +import type { HydratedCommunityDocument } from '@/types/mongoose/community'; + +export const postCreateSchema = asOpenapi('PostCreateBody', z.object({ + body: z.string().optional(), + feelingId: z.number(), + isSpoiler: z.boolean().default(false), + isAppJumpable: z.boolean().default(false), + painting: z.object({ + file: z.base64(), + isBmp: z.boolean() + }).optional(), + screenshot: z.object({ + file: z.base64(), + titleId: z.string() + }).optional(), + languageId: z.number().optional(), + countryId: z.number().optional(), + platformId: z.number().optional(), + regionId: z.number().optional() +})); + +export type PostCreateBody = z.infer; + +export type PostCreateOptions = { + body: PostCreateBody; + author: { + pid: number; + screenName: string; + miiData: string; + verified: boolean; + }; + parentPost: HydratedPostDocument | null; + community: HydratedCommunityDocument; +}; + +const defaultMiiFaceFilename = 'normal_face.png'; +const miiFaceFilenameMap: Record = { + 0: 'normal_face.png', + 1: 'smile_open_mouth.png', + 2: 'wink_left.png', + 3: 'surprise_open_mouth.png', + 4: 'frustrated.png', + 5: 'sorrow.png' +}; + +/** + * Create a new post from an input body + * Warning: Does not check if it should be posted, validation should be done outside of this method + */ +export async function createNewPost(ops: PostCreateOptions): Promise { + const body = ops.body; + const postId = await generatePostUID(21); + + let paintings: PaintingUrls | null = null; + if (body.painting) { + paintings = await uploadPainting({ + blob: body.painting.file, + autodetectFormat: false, + isBmp: body.painting.isBmp, + pid: ops.author.pid, + postId + }); + if (paintings === null) { + throw new Error('Failed to upload paintings'); + } + } + + let screenshots = null; + if (body.screenshot) { + const shotMode = getShotModeForTitleId(ops.community, body.screenshot.titleId); + if (shotMode !== 'block') { + screenshots = await uploadScreenshot({ + blob: body.screenshot.file, + pid: ops.author.pid, + postId + }); + if (screenshots === null) { + throw new Error('Failed to upload screenshots'); + } + } + } + + const miiFace = miiFaceFilenameMap[body.feelingId] ?? defaultMiiFaceFilename; + + const postBody = body.body?.replaceAll('\r\n', '\n'); // Clean up \r\n + if (postBody && getInvalidPostRegex().test(postBody)) { + throw new Error('Invalid characters found in post body'); + } + + if (postBody && postBody.length > 280) { + throw new Error('Post body is top long'); + } + + const document: IPostInput = { + title_id: ops.community.title_id[0], + community_id: ops.community.olive_community_id, + screen_name: ops.author.screenName, + body: postBody, + painting: paintings?.blob ?? '', + painting_img: paintings?.img ?? '', + painting_big: paintings?.big ?? '', + screenshot: screenshots?.full ?? '', + screenshot_big: screenshots?.big ?? '', + screenshot_length: screenshots?.fullLength ?? 0, + screenshot_thumb: screenshots?.thumb ?? '', + screenshot_aspect: screenshots?.aspect ?? '', + country_id: body.countryId ?? 49, + created_at: new Date(), + feeling_id: body.feelingId, + id: postId, + is_autopost: 0, + is_spoiler: body.isSpoiler ? 1 : 0, + is_app_jumpable: body.isAppJumpable ? 1 : 0, + language_id: body.languageId, + mii: ops.author.miiData, + mii_face_url: `${config.cdnUrl}/mii/${ops.author.pid}/${miiFace}`, + pid: ops.author.pid, + platform_id: body.platformId ?? 0, + region_id: body.regionId ?? 2, + verified: ops.author.verified, + parent: ops.parentPost?.id + }; + + const maxDuplicatePostAgeMs = 5 * 60 * 1000; + const duplicatePost = await getDuplicatePosts(ops.author.pid, document, maxDuplicatePostAgeMs); + if (duplicatePost) { + throw new Error('Duplicate post found'); + } + + if (document.body === '' && document.painting === '' && document.screenshot === '') { + throw new Error('No valid post content'); + } + + // Automod + const automodRules = await AutomodRule.find({ enabled: true }); + const automodEval = evaluateAutomodRules(document, automodRules); + const automodResult = await performAutomodAction(document, automodEval); + if (!automodResult.allowPost) { + throw errors.for('automod_prevented'); + } + + const newPost = await Post.create(document); + + if (ops.parentPost) { + await Post.findOneAndUpdate({ + id: ops.parentPost.id + }, { + $inc: { + reply_count: 1 + } + }); + } + + return newPost; +} + +async function generatePostUID(length: number): Promise { + let id = Buffer.from(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(length * 2))), 'binary').toString('base64').replace(/[+/]/g, '').substring(0, length); + const inuse = await Post.findOne({ id }); + id = (inuse ? await generatePostUID(length) : id); + return id; +} diff --git a/apps/miiverse-api/src/util.ts b/apps/miiverse-api/src/util.ts index 2f7432c9..424c975f 100644 --- a/apps/miiverse-api/src/util.ts +++ b/apps/miiverse-api/src/util.ts @@ -1,4 +1,4 @@ -import crypto from 'node:crypto'; +import crypto, { randomUUID } from 'node:crypto'; import { DeleteObjectsCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import crc32 from 'crc/crc32'; import { config } from '@/config'; @@ -300,3 +300,7 @@ export async function performAutomodAction(post: IPostInput, evaluation: Automod throw new Error('Invalid automod evaluation'); } + +export function genId(): string { + return randomUUID(); +} diff --git a/apps/miiverse-api/tsconfig.json b/apps/miiverse-api/tsconfig.json index 991abd15..6b03984e 100644 --- a/apps/miiverse-api/tsconfig.json +++ b/apps/miiverse-api/tsconfig.json @@ -22,6 +22,7 @@ } }, "include": [ - "src" + "src", + "prisma.config.ts" ] } \ No newline at end of file diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..038675b6 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,70 @@ +# Development setup + +To get set up with a local instance of Juxtaposition, you need the following things: +- [Docker Desktop](https://docs.docker.com/get-started/get-docker/) (Or docker engine if you're used to that) +- [NodeJS 20+](https://nodejs.org/en/download) + +After you have those installed, we will need to set up the following services: +- **Dependent services** - These are all tools that the network needs, like databases and proxies. They run in docker +- **Juxtaposition UI** - The service that serves all the frontend for all platforms +- **Miiverse API** - This is the backend of Juxtaposition UI and miiverse functionality, it's required for the UI to run + +## 1. Setting up `Dependent services` + +1. Run `docker compose up -d` whiel being in the `/.docker` folder. +2. Follow the initialization steps listed in [`/.docker/README.md`](../.docker/README.md). + +That's all, now move on the next step! + +## 2. Running `miiverse-api` + +1. Go into the `/apps/miiverse-api` folder, create the file `.env` with the following contents: + ```sh + PN_MIIVERSE_API_USE_PRESETS=docker + ``` +2. Install required dependencies with `npm i` +3. Initialize the database with `npm run migration:deploy` +4. Run the service with `npm run dev` + +You have to keep this server running while testing and using Juxtaposition. + +**Tip:** Read [`Extra topics / Databases and migrations`](./extra-topics.md) to learn how to work with the databases of Juxtaposition. + +## 3. Running `juxtposition-ui` + +1. Go into the `/apps/juxtaposition-ui` folder, create the file `.env` with the following contents: + ```sh + PN_JUXTAPOSITION_UI_USE_PRESETS=docker + ``` +2. Install required dependencies with `npm i` +3. Run the service with `npm run dev` + +You have also have to keep this server running while testing and using Juxtaposition. + +### 4. Connecting with browser + +If you want to connect to Juxtaposition through your browser, follow these steps: + +1. Install Firefox +2. In firefox, create a new profile for Juxtaposition by going to `profiles > new profile`. Choose a cool name like `juxt`. +3. Go into settings, search for `connection and software security`. Click `Advanced settings` and then `Configure proxy`, configure the following: + - Select `Manual proxy configuration` + - HTTP proxy: `localhost` + - HTTP proxy port: `888` + - Select `Also use this proxy for HTTPS` + - Keep the rest as default & press OK +4. Go to `Extensions and themes`, select the settings icon and choose `Install extension from file` + - Choose the file at `.docker/juxt-cookie-sync/juxt-cookie-sync-1.0.xpi` + - This will log you in to all Juxtaposition platforms when using your firefox profile. + +Done! Now when you use this firefox profile, it will be connected to your local Juxtaposition instance. + +### 5. Done! + +You now have a working local setup of Juxtaposition! + +Here are a couple useful links: +- [Proxy dashboard](http://localhost:8081?token=letmein) (password is `letmein`) +- [Local Juxtaposition web](https://juxt.pretendo.network/) (Use the firefox profile) +- [Local Juxtaposition portal (WiiU)](https://portal.olv.pretendo.cc/) (Use the firefox profile) +- [Local Juxtaposition ctr (3ds)](https://ctr.olv.pretendo.cc/) (Use the firefox profile) diff --git a/docs/extra-topics.md b/docs/extra-topics.md new file mode 100644 index 00000000..9a899ad1 --- /dev/null +++ b/docs/extra-topics.md @@ -0,0 +1,23 @@ +# Extra topics + +This file contains some extra topics that are helpful for development. + +## 1. Databases and migrations + +Juxtaposition currently uses two databases: postgresql (with prisma) and mongodb (with mongoose). +Mongodb is being phased out and replaced with postgresql. + +Here are some situations which require extra steps to work with the databases, run all steps. + +Before starting the app: +- `mongodb`: No steps required +- `postgresql`: Deploy the new migrations using `npm run migration:deploy` + +Changing database models: +- `mongodb`: Automatic, no manual steps needed +- `postgresql`: Create a migration using `npm run migration:create` and deploy it using `npm run migration:deploy` + +Creating a data migration: +- `mongodb`: Add a mongosh script to `/migrations` +- `postgresql`: Create a blank migration using `npm run migration:create` and fill it with your SQL script to do a data migration +- Cross-db data migrations: Create a NodeJS script in `/migrations` and give it a package.json for it's dependencies. diff --git a/migrations/6-creation-date.js b/migrations/6-creation-date.js new file mode 100644 index 00000000..3e396e19 --- /dev/null +++ b/migrations/6-creation-date.js @@ -0,0 +1,30 @@ +// Settings and Reports had an incorrect creation timestamp. +// This migration extracts the time from the _id field and moves it into the created_at field. +// --- +// This script is intended to run in mongosh: +// $ mongosh mongodb://localhost:27017/mydb 6-creation-date.js + +print(`Correcting incorrect creation date`); + +const resultSettings = db.settings.updateMany({}, [ + { + $set: { + created_at: { + $toDate: "$_id" + } + } + } +]); + +const resultReports = db.reports.updateMany({}, [ + { + $set: { + created_at: { + $toDate: "$_id" + } + } + } +]); + +print(`Updated ${resultSettings.modifiedCount} Settings documents!`); +print(`Updated ${resultReports.modifiedCount} Reports documents!`); diff --git a/migrations/6-fix-followers.js b/migrations/7-fix-followers.js similarity index 94% rename from migrations/6-fix-followers.js rename to migrations/7-fix-followers.js index 34d31737..cfa4b944 100644 --- a/migrations/6-fix-followers.js +++ b/migrations/7-fix-followers.js @@ -5,7 +5,7 @@ // That data can be fixed once it's moved to a relational database // --- // This script is intended to run in mongosh: -// $ mongosh mongodb://localhost:27017/mydb 6-fix-followers.js +// $ mongosh mongodb://localhost:27017/mydb 7-fix-followers.js const user = db.contents.findOne({ }); diff --git a/migrations/8-notification-to-pg/package-lock.json b/migrations/8-notification-to-pg/package-lock.json new file mode 100644 index 00000000..54bd4df5 --- /dev/null +++ b/migrations/8-notification-to-pg/package-lock.json @@ -0,0 +1,323 @@ +{ + "name": "8-notification-to-pg", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "dotenv": "^17.4.2", + "mongodb": "^7.4.0", + "pg": "^8.22.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mongodb": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.4.0.tgz", + "integrity": "sha512-giySkkdYiwoBFo/oCc8nzov3xOYZ/sB8OpAYk5GINRLEjVw0LDsm8xgQL0XMTyU4extQlDZjhdUr1ZEwKFaazw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "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", + "peer": true, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/migrations/8-notification-to-pg/package.json b/migrations/8-notification-to-pg/package.json new file mode 100644 index 00000000..925e97f2 --- /dev/null +++ b/migrations/8-notification-to-pg/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "scripts": { + "start": "node ./script.ts" + }, + "dependencies": { + "dotenv": "^17.4.2", + "mongodb": "^7.4.0", + "pg": "^8.22.0" + } +} diff --git a/migrations/8-notification-to-pg/script.ts b/migrations/8-notification-to-pg/script.ts new file mode 100644 index 00000000..16fcbb23 --- /dev/null +++ b/migrations/8-notification-to-pg/script.ts @@ -0,0 +1,261 @@ +import 'dotenv/config' +import { MongoClient, type Document } from "mongodb"; +import { Client as PgClient } from "pg"; +import { randomUUID } from "node:crypto"; + +const MONGO_URI = process.env.MONGO_URI; +const POSTGRES_URL = process.env.POSTGRES_URL; + +if (!MONGO_URI || !POSTGRES_URL) { + console.error("Missing MONGO_URI or POSTGRES_URL"); + process.exit(1); +} + +const mongo = new MongoClient(MONGO_URI); +await mongo.connect(); + +const db = mongo.db(); +const notifications = db.collection("notifications"); + +const pg = new PgClient({ + connectionString: POSTGRES_URL, +}); +await pg.connect(); + +function parseDateString(str: string): Date { + if (str == "null" || str == "Invalid date") { + return new Date(); + } else { + return new Date(str); + } +} + +function createMetaFromDoc(doc: Document): { type: string, content: Record} | null { + if (doc.type === 'follow') { + const users = doc.users.map((v: any) => ({ + timestamp: v.timestamp, + pid: Number(v.user), + })); + return { + type: 'Follow', + content: { + users, + } + } + } + + if (doc.type === 'notice') { + const text = doc.text; + + // post removal with reason + // Formats: + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason" + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + // * Your comment "hKikmHAKf8c8RFCK2avCn" has been removed for the following reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + if (text.startsWith("Your") && text.includes("has been removed for")) { + const postType = text.startsWith("Your comment") ? 'comment' : 'post'; + if (postType === 'post' && !text.startsWith("Your post")) { + console.warn(`Could not determine post type ${doc._id}: "${text}"`) + return null; + } + const [_,__,postId,reason] = text.match(/your (post|comment) "([^"]+)" [\s\S]*reason: "([\s\S]*)"(\.? ?Click this message |$)/i) + if (!postId || !reason) { + console.warn(`Could not extract reason and postId ${doc._id}: "${text}"`) + return null; + } + return { + type: 'PostDeleted', + content: { + postId, + reason, + postType, + } + } + } + + // post removal without reason + // Formats: + // * Your post "hKikmHAKf8c8RFCK2avCn" has been removed. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + // * Your comment "hKikmHAKf8c8RFCK2avCn" has been removed. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/juxt-mods/). + if (text.startsWith("Your") && text.includes("has been removed")) { + const postType = text.startsWith("Your comment") ? 'comment' : 'post'; + if (postType === 'post' && !text.startsWith("Your post")) { + console.warn(`Could not determine post type ${doc._id}: "${text}"`) + return null; + } + const [_,__,postId] = text.match(/your (post|comment) "([^"]*)"/i) + if (!postId) { + console.warn(`Could not extract postId ${doc._id}: "${text}"`) + return null; + } + return { + type: 'PostDeleted', + content: { + postId, + reason: undefined, + postType, + } + } + } + + // Limited from posting with date + // Formats: + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Reason: "test reason".Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Jun 11, 2026, 11:16 PM UTC. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting until Wed Jun 11 2025 23:44:00 GMT+0000. Reason: "test reason". If you have any questions contact the moderators in the Discord server or forum + // Dates can be "null" or "Invalid date" as well + if (text.toLowerCase().includes("limited from posting until")) { + const [_,dateStr] = text.match(/Posting until ([^.]+)\./i); + if (!dateStr) { + console.warn(`Could not extract date ${doc._id}: "${text}"`) + return null; + } + + let reason: undefined | string; + if (text.includes("Reason:")) { + const [_,reason] = text.match(/Reason: "([\s\S]*)"\.? ?(Click this message|If you have any questions)/i); + if (reason === undefined) { + console.warn(`Could not extract reason ${doc._id}: "${text}"`) + return null; + } + } + + return { + type: 'LimitedFromPosting', + content: { + until: parseDateString(dateStr).toISOString(), + reason, + } + } + } + + // Limited from posting without date + // formats: + // * You have been Limited from Posting. Reason: "test reason". Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting. Reason: "test reason".Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + // * You have been Limited from Posting. Click this message to view the Juxtaposition Code of Conduct. If you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/). + if (text.toLowerCase().includes("limited from posting")) { + let reason: undefined | string; + if (text.includes("Reason:")) { + const [_,reason] = text.match(/Reason: "([\s\S]*)"\.? ?(Click this message|If you have any questions)/i); + if (reason === undefined) { + console.warn(`Could not extract reason ${doc._id}: "${text}"`) + return null; + } + } + + return { + type: 'LimitedFromPosting', + content: { + until: undefined, + reason, + } + } + } + } + + if (doc.type === 'notice') { + console.warn(`Recieved generic notice for notification ${doc._id}: "${doc.text}"`) + return { + type: 'System', + content: { + text: doc.text, + link: doc.link, + imagePath: doc.image, + } + } + } + + return null; // Unknown type +} + +async function main() { + console.log("Starting migration"); + const cursor = notifications.find({}); + let migrated = 0; + + while (await cursor.hasNext()) { + const notification = await cursor.next(); + if (!notification) { + console.warn(`Skipping document: Received null`); + continue; + } + console.log(`Processing ${notification._id}`); + + let meta; + try { + meta = createMetaFromDoc(notification); + } catch (err) { + console.warn(err, `Skipping ${notification._id}: Content extraction broke: ${notification.text}`); + continue; + } + if (!meta) { + console.warn(`Skipping ${notification._id}: No content found`); + continue; + } + + try { + await pg.query("BEGIN"); + + const notifId = randomUUID(); + const createdAt = notification._id.getTimestamp() + await pg.query( + ` + INSERT INTO notifications ( + id, + type, + content, + created_at, + updated_at + ) + VALUES ($1, $2, $3::jsonb, $4, $5) + `, + [ + notifId, + meta.type, + JSON.stringify(meta.content), + createdAt, + notification.lastUpdated ?? createdAt, + ] + ); + + await pg.query( + ` + INSERT INTO notification_recipients ( + id, + pid, + has_read, + notification_id + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING + `, + [ + randomUUID(), + Number(notification.pid), + notification.read ?? false, + notifId, + ] + ); + + await pg.query("COMMIT"); + + migrated++; + } catch (err) { + await pg.query("ROLLBACK"); + console.error(`Failed to migrate notification ${notification._id}`, err); + } + } + + console.log(`Done. Migrated ${migrated} notifications.`); + +} + +await main().catch((err) => { + console.error(err); + process.exit(1); +}); + +await pg.end(); +await mongo.close(); diff --git a/package-lock.json b/package-lock.json index 9161cb38..03f4fcc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "version": "3.0.0", "license": "AGPL-3.0-only", "dependencies": { - "@aws-sdk/client-s3": "^3.1004.0", "@hey-api/openapi-ts": "^0.95.0", "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", @@ -31,17 +30,13 @@ "express-prom-bundle": "^7.0.2", "express-rate-limit": "^8.2.1", "express-session": "^1.19.0", - "hashmap": "^2.4.0", "i18next": "^25.8.18", "include-media": "github:eduardoboucas/include-media#78c6aeb", "luxon": "^3.7.2", "method-override": "^3.0.0", "moment": "^2.30.1", - "mongoose": "^8.23.0", "multer": "^2.1.1", "nice-grpc": "^2.1.14", - "node-snowflake": "0.0.1", - "pako": "^2.1.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -51,7 +46,6 @@ "react-dom": "^19.2.4", "react-i18next": "^16.5.8", "redis": "^5.11.0", - "tsx": "^4.21.0", "zod": "^4.3.6" }, "devDependencies": { @@ -65,7 +59,6 @@ "@repo/esbuild-plugin-spritesmith": "^0.0.0", "@types/cookie-parser": "^1.4.10", "@types/express-session": "^1.18.2", - "@types/hashmap": "^2.3.4", "@types/luxon": "^3.7.1", "@types/method-override": "^3.0.0", "@types/node": "^24.12.0", @@ -81,6 +74,7 @@ "nodemon": "^3.1.14", "npm-run-all": "^4.1.5", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3" } }, @@ -396,6 +390,8 @@ "@imagemagick/magick-wasm": "^0.0.38", "@neato/config": "^4.1.0", "@pretendonetwork/grpc": "^2.4.3", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@repo/grpc-client": "^0.0.0", "crc": "^4.3.2", "express": "^5.2.1", @@ -408,6 +404,7 @@ "nice-grpc": "^2.1.14", "node-snowflake": "0.0.1", "pako": "^2.1.0", + "pg": "^8.22.0", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -421,8 +418,10 @@ "@types/express": "^4.17.17", "@types/multer": "^2.1.0", "@types/pako": "^2.0.4", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "eslint": "^9.39.4", + "prisma": "^7.8.0", "tsup": "^8.5.1", "typescript": "^5.9.3" } @@ -1724,6 +1723,37 @@ "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -2577,6 +2607,19 @@ "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2717,6 +2760,13 @@ "wasm-feature-detect": "^1.2.11" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", @@ -3191,6 +3241,361 @@ "integrity": "sha512-l6dfw5MHe9k3Q4/6vanuHUHJK82DjwxGrv4dfdRc47WEcgLyGCJx8ccmgL/H3MXwmcpmJNwhEbMQ5l8xJFHccQ==", "dev": true }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/config/node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/@prisma/config/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/config/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/config/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@prisma/config/node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/@prisma/config/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/@prisma/config/node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/@prisma/config/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3255,6 +3660,153 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@redis/bloom": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", @@ -3272,6 +3824,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4439,6 +4992,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -4467,12 +5021,6 @@ "@types/express": "*" } }, - "node_modules/@types/hashmap": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@types/hashmap/-/hashmap-2.3.4.tgz", - "integrity": "sha512-IoFSb7S7cwCM23HcAhUS57DBWPU0dPSF6Wz4M4y0S+B1xgGmM08WOzd1wBldesmZ28jhzRNmXda/FO5uY3tLlw==", - "dev": true - }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -4544,6 +5092,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -4558,8 +5117,9 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4697,6 +5257,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5187,6 +5748,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5507,6 +6069,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5523,6 +6095,13 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/bin-pack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bin-pack/-/bin-pack-1.0.2.tgz", @@ -5644,6 +6223,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5939,6 +6519,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6064,8 +6657,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -6255,7 +6847,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cwise-compiler": { @@ -6355,6 +6947,16 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -6443,6 +7045,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6552,6 +7164,17 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -6564,6 +7187,16 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -6580,6 +7213,19 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6769,6 +7415,7 @@ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6902,6 +7549,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7070,6 +7718,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7440,6 +8089,7 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "~0.7.2", "cookie-signature": "~1.0.7", @@ -7495,6 +8145,29 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -7505,7 +8178,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "devOptional": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -7540,6 +8213,23 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", @@ -7723,6 +8413,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -7776,6 +8483,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -7822,6 +8530,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7870,6 +8588,13 @@ "through": "^2.3.4" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8034,6 +8759,20 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -8126,14 +8865,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hashmap": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/hashmap/-/hashmap-2.4.0.tgz", - "integrity": "sha512-Ngj48lhnxJdnBAEVbubKBJuN1elfVLZJs94ZixRi98X3GCU4v6pgj9qRkHt6H8WaVJ69Wv0r1GhtS7hvF9zCgg==", - "engines": { - "node": "*" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8150,6 +8881,17 @@ "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -8195,6 +8937,13 @@ "node": ">= 0.8" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/i18next": { "version": "25.8.18", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", @@ -8214,6 +8963,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6" }, @@ -8650,6 +9400,13 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9086,6 +9843,22 @@ "loose-envify": "cli.js" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -9430,6 +10203,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9441,6 +10252,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -10242,6 +11066,96 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "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", + "peer": true, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10523,6 +11437,59 @@ } } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "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/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -10544,6 +11511,41 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", @@ -10570,6 +11572,7 @@ "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" @@ -10589,6 +11592,25 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -10649,6 +11671,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -10735,6 +11774,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10744,6 +11784,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10864,6 +11905,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.11.0", "@redis/client": "5.11.0", @@ -10917,6 +11959,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, "node_modules/replace-ext": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz", @@ -10933,6 +11985,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -10972,6 +12034,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11087,7 +12159,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11213,7 +12284,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.99.0.tgz", "integrity": "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -11262,7 +12332,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.99.0" } @@ -11279,7 +12348,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11296,7 +12364,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11313,7 +12380,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11330,7 +12396,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11347,7 +12412,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11364,7 +12428,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11381,7 +12444,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11398,7 +12460,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11415,7 +12476,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11432,7 +12492,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11449,7 +12508,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11466,7 +12524,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11483,7 +12540,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11500,7 +12556,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11517,7 +12572,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.99.0" } @@ -11534,7 +12588,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11551,7 +12604,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -11561,7 +12613,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11696,6 +12747,12 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -11868,6 +12925,19 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -12033,6 +13103,16 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -12051,6 +13131,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -12394,7 +13481,6 @@ "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -12407,7 +13493,6 @@ "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -12757,6 +13842,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -12881,6 +13967,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13003,6 +14090,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -13101,6 +14189,21 @@ "node": ">= 0.4.0" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -13116,8 +14219,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vary": { "version": "1.1.2", @@ -13421,11 +14523,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }