A modified version of Wafrn used on https://wf.jbc.lol (mirror of https://git.jbc.lol/jbcrn/wf.jbc.lol which is a mirror of https://codeberg.org/jbcarreon123/wf.jbc.lol)

Compare changes

Choose any two refs to compare.

Changed files
+2380 -1193
.vscode
cache
docs
install
logs
packages
-8
.env.example
··· 54 54 POSTGRES_METRICS_DBNAME=pgwatch_metrics 55 55 GF_SECURITY_ADMIN_PASSWORD= 56 56 57 - # OpenID Connect auth 58 - # Only do this if you know what you are doing! See docs/openid.md for more info 59 - OIDC_ENABLED=false 60 - OIDC_ISSUER=https://auth.localhost 61 - OIDC_CLIENT_ID=wafrn 62 - OIDC_CLIENT_SECRET=secret 63 - OIDC_AUTH_NAME=OpenID 64 - 65 57 # Debugging 66 58 ENABLE_RAW_OUTPUT=false
+3 -3
.gitignore
··· 8 8 /tmp 9 9 /out-tsc 10 10 11 + # custom caddy config 12 + packages/caddy 11 13 12 14 # Only exists if Bazel was run 13 15 /bazel-out ··· 51 53 .lite_workspace.lua 52 54 53 55 # default docker compose 54 - nohup.out 55 - mydockersimages.list 56 - pds.env 56 + docker-compose.yml
+1 -1
.vscode/settings.json
··· 1 1 { 2 - // "editor.formatOnSave": true 2 + "editor.formatOnSave": true 3 3 }
cache/.gitkeep

This is a binary file and will not be displayed.

+296
docker-compose.advanced.metrics.yml
··· 1 + services: 2 + backend: &default_backend 3 + build: &default_backend_build 4 + context: . 5 + dockerfile: packages/backend/Dockerfile 6 + # these args configure private env vars for the backend and public env vars for the frontend 7 + depends_on: 8 + db: 9 + condition: service_healthy 10 + redis: 11 + condition: service_started 12 + frontend: 13 + condition: service_started 14 + migration: 15 + condition: service_completed_successfully 16 + restart: unless-stopped 17 + environment: &default_backend_env_vars 18 + NODE_ENV: production 19 + ADMIN_USER: ${ADMIN_USER} 20 + ADMIN_EMAIL: ${ADMIN_EMAIL} 21 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} 22 + JWT_SECRET: ${JWT_SECRET} 23 + DOMAIN_NAME: ${DOMAIN_NAME} 24 + 25 + CACHE_DOMAIN: ${CACHE_DOMAIN} 26 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 27 + 28 + SMTP_HOST: ${SMTP_HOST} 29 + SMTP_USER: ${SMTP_USER} 30 + SMTP_PORT: ${SMTP_PORT} 31 + SMTP_PASSWORD: ${SMTP_PASSWORD} 32 + SMTP_FROM: ${SMTP_FROM} 33 + 34 + POSTGRES_USER: ${POSTGRES_USER} 35 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 36 + POSTGRES_DBNAME: ${POSTGRES_DBNAME} 37 + 38 + WEBPUSH_EMAIL: ${WEBPUSH_EMAIL} 39 + WEBPUSH_PRIVATE: ${WEBPUSH_PRIVATE} 40 + WEBPUSH_PUBLIC: ${WEBPUSH_PUBLIC} 41 + 42 + ENABLE_BSKY: ${ENABLE_BSKY} 43 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 44 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 45 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 46 + 47 + USE_WORKERS: false 48 + LOG_SQL_QUERIES: ${LOG_SQL_QUERIES:-} 49 + UPLOAD_LIMIT: ${UPLOAD_LIMIT:-} 50 + POSTS_PER_PAGE: ${POSTS_PER_PAGE:-} 51 + LOG_LEVEL: ${LOG_LEVEL:-} 52 + BLOCKLIST_URI: ${BLOCKLIST_URI:-} 53 + FRONTEND_PATH: ${FRONTEND_PATH:-} 54 + DISABLE_REQUIRE_SEND_EMAIL: ${DISABLE_REQUIRE_SEND_EMAIL:-} 55 + BLOCKED_IPS: ${BLOCKED_IPS:-} 56 + REVIEW_REGISTRATIONS: ${REVIEW_REGISTRATIONS:-} 57 + IGNORE_BLOCK_HOSTS: ${IGNORE_BLOCK_HOSTS:-} 58 + 59 + FRONTEND_LOGO: ${FRONTEND_LOGO:-} 60 + FRONTEND_API_URL: ${FRONTEND_API_URL:-} 61 + FRONTEND_MEDIA_URL: ${FRONTEND_MEDIA_URL:-} 62 + FRONTEND_CACHE_URL: ${FRONTEND_CACHE_URL:-} 63 + FRONTEND_CACHE_BACKUP_URLS: ${FRONTEND_CACHE_BACKUP_URLS:-} 64 + FRONTEND_SHORTEN_POSTS: ${FRONTEND_SHORTEN_POSTS:-} 65 + FRONTEND_DISABLE_PWA: ${FRONTEND_DISABLE_PWA:-} 66 + FRONTEND_MAINTENANCE: ${FRONTEND_MAINTENANCE:-} 67 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 68 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 69 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 70 + 71 + FRONTEND_FQDN_URL: https://${DOMAIN_NAME} 72 + 73 + ENABLE_RAW_OUTPUT: ${ENABLE_RAW_OUTPUT:-} 74 + deploy: 75 + mode: replicated 76 + replicas: 3 77 + volumes: 78 + - ./packages/backend/uploads:/app/packages/backend/uploads 79 + - ./packages/backend/cache:/app/packages/backend/cache 80 + - frontend:/app/packages/frontend:ro 81 + 82 + migration: 83 + <<: *default_backend 84 + depends_on: 85 + db: 86 + condition: service_healthy 87 + redis: 88 + condition: service_started 89 + frontend: 90 + condition: service_started 91 + restart: no 92 + deploy: 93 + mode: replicated 94 + replicas: 1 95 + command: "npm exec tsx migrate.ts init-container" 96 + 97 + frontend: 98 + restart: unless-stopped 99 + build: 100 + context: . 101 + dockerfile: packages/frontend/Dockerfile 102 + ports: 103 + - 80:80 104 + - 443:443 105 + environment: 106 + DOMAIN_NAME: ${DOMAIN_NAME} 107 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 108 + CACHE_DOMAIN: ${CACHE_DOMAIN} 109 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 110 + ACME_EMAIL: ${ACME_EMAIL} 111 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 112 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 113 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 114 + CACHE_HOST: "cache:9000" 115 + BACKEND_HOST: "wafrn-backend-1:9000 wafrn-backend-2:9000 wafrn-backend-3:9000" 116 + WEBSOCKET_HOST: "wafrn-websocket-1:9000" 117 + 118 + volumes: 119 + - "caddy:/data" 120 + - "frontend:/var/www/html/frontend" 121 + - ./packages/backend/uploads:/var/www/html/uploads 122 + - ./packages/caddy:/etc/caddy/config 123 + 124 + db: 125 + build: 126 + context: monitoring/database 127 + dockerfile: Dockerfile 128 + restart: unless-stopped 129 + shm_size: '2gb' 130 + environment: 131 + POSTGRES_USER: ${POSTGRES_USER} 132 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 133 + POSTGRES_DB: ${POSTGRES_DBNAME} 134 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 135 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 136 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 137 + volumes: 138 + - dbpg:/var/lib/postgresql/data 139 + 140 + adminer: 141 + image: adminer 142 + restart: unless-stopped 143 + 144 + redis: 145 + image: redis:7.2.4 146 + restart: unless-stopped 147 + volumes: 148 + - redis:/data 149 + 150 + pds: 151 + image: ghcr.io/bluesky-social/pds:0.4 152 + restart: unless-stopped 153 + profiles: 154 + - bluesky 155 + environment: 156 + PDS_HOSTNAME: ${PDS_DOMAIN_NAME} 157 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 158 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 159 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 160 + PDS_DATA_DIRECTORY: /pds 161 + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 162 + PDS_BLOB_UPLOAD_LIMIT: 52428800 163 + PDS_DID_PLC_URL: "https://plc.directory" 164 + PDS_BSKY_APP_VIEW_URL: "https://api.bsky.app" 165 + PDS_BSKY_APP_VIEW_DID: "did:web:api.bsky.app" 166 + PDS_REPORT_SERVICE_URL: "https://mod.bsky.app" 167 + PDS_REPORT_SERVICE_DID: "did:plc:ar7c4by46qjdydhdevvrndac" 168 + PDS_CRAWLERS: "https://bsky.network, https://atproto.africa" 169 + PDS_EMAIL_SMTP_URL: "smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" 170 + PDS_EMAIL_FROM_ADDRESS: "${SMTP_FROM}" 171 + LOG_ENABLED: true 172 + volumes: 173 + - pds:/pds 174 + 175 + pds_worker: 176 + <<: *default_backend 177 + deploy: 178 + mode: replicated 179 + replicas: 1 180 + profiles: 181 + - bluesky 182 + command: "npm exec tsx atproto.ts" 183 + 184 + cache: 185 + <<: *default_backend 186 + deploy: 187 + mode: replicated 188 + replicas: 1 189 + websocket: 190 + <<: *default_backend 191 + deploy: 192 + mode: replicated 193 + replicas: 1 194 + command: "npm exec tsx websocket.ts" 195 + 196 + workers: 197 + <<: *default_backend 198 + build: 199 + <<: *default_backend_build 200 + environment: 201 + <<: *default_backend_env_vars 202 + USE_WORKERS: true 203 + deploy: 204 + mode: replicated 205 + replicas: 3 206 + 207 + prometheus: 208 + restart: unless-stopped 209 + image: prom/prometheus:latest 210 + volumes: 211 + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml 212 + - prometheus_data:/prometheus 213 + command: 214 + - '--config.file=/etc/prometheus/prometheus.yml' 215 + - '--storage.tsdb.path=/prometheus' 216 + - '--web.console.libraries=/usr/share/prometheus/console_libraries' 217 + - '--web.console.templates=/usr/share/prometheus/consoles' 218 + 219 + cadvisor: 220 + restart: unless-stopped 221 + image: gcr.io/cadvisor/cadvisor:latest 222 + command: 223 + - '-port=8081' 224 + environment: 225 + CADVISOR_HEALTHCHECK_URL: http://localhost:8081/healthz 226 + volumes: 227 + - /:/rootfs:ro 228 + - /var/run:/var/run:rw 229 + - /sys:/sys:ro 230 + - /var/lib/docker/:/var/lib/docker:ro 231 + 232 + node-exporter: 233 + restart: unless-stopped 234 + image: prom/node-exporter:latest 235 + volumes: 236 + - /proc:/host/proc:ro 237 + - /sys:/host/sys:ro 238 + - /:/rootfs:ro 239 + command: 240 + - '--path.procfs=/host/proc' 241 + - '--path.sysfs=/host/sys' 242 + - '--collector.filesystem.ignored-mount-points="^/(sys|proc|dev|host|etc)($$|/)"' 243 + 244 + grafana: 245 + build: 246 + context: monitoring/grafana 247 + dockerfile: Dockerfile 248 + volumes: 249 + - grafana_data:/var/lib/grafana 250 + restart: unless-stopped 251 + environment: 252 + GF_SERVER_HTTP_PORT: 2345 253 + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} 254 + GF_USERS_ALLOW_SIGN_UP: false 255 + 256 + GF_SMTP_ENABLED: true 257 + GF_SMTP_HOST: ${SMTP_HOST}:${SMTP_PORT} 258 + GF_SMTP_FROM_ADDRESS: ${SMTP_FROM} 259 + GF_SERVER_DOMAIN: ${DOMAIN_NAME} 260 + GF_SMTP_FROM_NAME: ${SMTP_FROM} 261 + GF_SMTP_USER: "${SMTP_USER}" 262 + GF_SMTP_PASSWORD: "${SMTP_PASSWORD}" 263 + 264 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 265 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 266 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 267 + 268 + pgwatch: 269 + build: 270 + context: monitoring/pgwatch 271 + dockerfile: Dockerfile 272 + args: 273 + POSTGRES_USER: ${POSTGRES_USER} 274 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 275 + POSTGRES_DB: ${POSTGRES_DBNAME} 276 + restart: unless-stopped 277 + environment: 278 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 279 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 280 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 281 + command: 282 + - "--web-disable=all" 283 + - "--sources=/sources.yaml" 284 + - "--sink=postgresql://${POSTGRES_METRICS_USER}:${POSTGRES_METRICS_PASSWORD}@db:5432/${POSTGRES_METRICS_DBNAME}" 285 + depends_on: 286 + db: 287 + condition: service_healthy 288 + 289 + volumes: 290 + dbpg: 291 + caddy: 292 + pds: 293 + frontend: 294 + redis: 295 + prometheus_data: 296 + grafana_data:
+207
docker-compose.advanced.yml
··· 1 + services: 2 + backend: &default_backend 3 + build: &default_backend_build 4 + context: . 5 + dockerfile: packages/backend/Dockerfile 6 + # these args configure private env vars for the backend and public env vars for the frontend 7 + depends_on: 8 + db: 9 + condition: service_healthy 10 + redis: 11 + condition: service_started 12 + frontend: 13 + condition: service_started 14 + migration: 15 + condition: service_completed_successfully 16 + restart: unless-stopped 17 + environment: &default_backend_env_vars 18 + NODE_ENV: production 19 + ADMIN_USER: ${ADMIN_USER} 20 + ADMIN_EMAIL: ${ADMIN_EMAIL} 21 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} 22 + JWT_SECRET: ${JWT_SECRET} 23 + DOMAIN_NAME: ${DOMAIN_NAME} 24 + 25 + CACHE_DOMAIN: ${CACHE_DOMAIN} 26 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 27 + 28 + SMTP_HOST: ${SMTP_HOST} 29 + SMTP_USER: ${SMTP_USER} 30 + SMTP_PORT: ${SMTP_PORT} 31 + SMTP_PASSWORD: ${SMTP_PASSWORD} 32 + SMTP_FROM: ${SMTP_FROM} 33 + 34 + POSTGRES_USER: ${POSTGRES_USER} 35 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 36 + POSTGRES_DBNAME: ${POSTGRES_DBNAME} 37 + 38 + WEBPUSH_EMAIL: ${WEBPUSH_EMAIL} 39 + WEBPUSH_PRIVATE: ${WEBPUSH_PRIVATE} 40 + WEBPUSH_PUBLIC: ${WEBPUSH_PUBLIC} 41 + 42 + ENABLE_BSKY: ${ENABLE_BSKY} 43 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 44 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 45 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 46 + 47 + USE_WORKERS: false 48 + LOG_SQL_QUERIES: ${LOG_SQL_QUERIES:-} 49 + UPLOAD_LIMIT: ${UPLOAD_LIMIT:-} 50 + POSTS_PER_PAGE: ${POSTS_PER_PAGE:-} 51 + LOG_LEVEL: ${LOG_LEVEL:-} 52 + BLOCKLIST_URI: ${BLOCKLIST_URI:-} 53 + FRONTEND_PATH: ${FRONTEND_PATH:-} 54 + DISABLE_REQUIRE_SEND_EMAIL: ${DISABLE_REQUIRE_SEND_EMAIL:-} 55 + BLOCKED_IPS: ${BLOCKED_IPS:-} 56 + REVIEW_REGISTRATIONS: ${REVIEW_REGISTRATIONS:-} 57 + IGNORE_BLOCK_HOSTS: ${IGNORE_BLOCK_HOSTS:-} 58 + 59 + FRONTEND_LOGO: ${FRONTEND_LOGO:-} 60 + FRONTEND_API_URL: ${FRONTEND_API_URL:-} 61 + FRONTEND_MEDIA_URL: ${FRONTEND_MEDIA_URL:-} 62 + FRONTEND_CACHE_URL: ${FRONTEND_CACHE_URL:-} 63 + FRONTEND_CACHE_BACKUP_URLS: ${FRONTEND_CACHE_BACKUP_URLS:-} 64 + FRONTEND_SHORTEN_POSTS: ${FRONTEND_SHORTEN_POSTS:-} 65 + FRONTEND_DISABLE_PWA: ${FRONTEND_DISABLE_PWA:-} 66 + FRONTEND_MAINTENANCE: ${FRONTEND_MAINTENANCE:-} 67 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 68 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 69 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 70 + 71 + FRONTEND_FQDN_URL: https://${DOMAIN_NAME} 72 + 73 + ENABLE_RAW_OUTPUT: ${ENABLE_RAW_OUTPUT:-} 74 + deploy: 75 + mode: replicated 76 + replicas: 3 77 + volumes: 78 + - ./packages/backend/uploads:/app/packages/backend/uploads 79 + - ./packages/backend/cache:/app/packages/backend/cache 80 + - frontend:/app/packages/frontend:ro 81 + 82 + migration: 83 + <<: *default_backend 84 + depends_on: 85 + db: 86 + condition: service_started 87 + redis: 88 + condition: service_started 89 + frontend: 90 + condition: service_started 91 + restart: no 92 + deploy: 93 + mode: replicated 94 + replicas: 1 95 + command: "npm exec tsx migrate.ts init-container" 96 + 97 + frontend: 98 + restart: unless-stopped 99 + build: 100 + context: . 101 + dockerfile: packages/frontend/Dockerfile 102 + ports: 103 + - 80:80 104 + - 443:443 105 + environment: 106 + DOMAIN_NAME: ${DOMAIN_NAME} 107 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 108 + CACHE_DOMAIN: ${CACHE_DOMAIN} 109 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 110 + ACME_EMAIL: ${ACME_EMAIL} 111 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 112 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 113 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 114 + CACHE_HOST: "cache:9000" 115 + BACKEND_HOST: "wafrn-backend-1:9000 wafrn-backend-2:9000 wafrn-backend-3:9000" 116 + WEBSOCKET_HOST: "wafrn-websocket-1:9000" 117 + volumes: 118 + - "caddy:/data" 119 + - "frontend:/var/www/html/frontend" 120 + - ./packages/backend/uploads:/var/www/html/uploads 121 + - ./packages/caddy:/etc/caddy/config 122 + 123 + db: 124 + image: postgres:17 125 + restart: unless-stopped 126 + shm_size: '2gb' 127 + environment: 128 + POSTGRES_USER: ${POSTGRES_USER} 129 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 130 + POSTGRES_DB: ${POSTGRES_DBNAME} 131 + volumes: 132 + - dbpg:/var/lib/postgresql/data 133 + 134 + adminer: 135 + image: adminer 136 + restart: unless-stopped 137 + 138 + redis: 139 + image: redis:7.2.4 140 + restart: unless-stopped 141 + volumes: 142 + - redis:/data 143 + 144 + pds: 145 + image: ghcr.io/bluesky-social/pds:0.4 146 + restart: unless-stopped 147 + profiles: 148 + - bluesky 149 + environment: 150 + PDS_HOSTNAME: ${PDS_DOMAIN_NAME} 151 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 152 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 153 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 154 + PDS_DATA_DIRECTORY: /pds 155 + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 156 + PDS_BLOB_UPLOAD_LIMIT: 52428800 157 + PDS_DID_PLC_URL: "https://plc.directory" 158 + PDS_BSKY_APP_VIEW_URL: "https://api.bsky.app" 159 + PDS_BSKY_APP_VIEW_DID: "did:web:api.bsky.app" 160 + PDS_REPORT_SERVICE_URL: "https://mod.bsky.app" 161 + PDS_REPORT_SERVICE_DID: "did:plc:ar7c4by46qjdydhdevvrndac" 162 + PDS_CRAWLERS: "https://bsky.network, https://atproto.africa" 163 + PDS_EMAIL_SMTP_URL: "smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" 164 + PDS_EMAIL_FROM_ADDRESS: "${SMTP_FROM}" 165 + LOG_ENABLED: true 166 + volumes: 167 + - pds:/pds 168 + 169 + pds_worker: 170 + <<: *default_backend 171 + profiles: 172 + - bluesky 173 + deploy: 174 + mode: replicated 175 + replicas: 1 176 + command: "npm exec tsx atproto.ts" 177 + 178 + cache: 179 + <<: *default_backend 180 + deploy: 181 + mode: replicated 182 + replicas: 1 183 + 184 + websocket: 185 + <<: *default_backend 186 + deploy: 187 + mode: replicated 188 + replicas: 1 189 + command: "npm exec tsx websocket.ts" 190 + 191 + workers: 192 + <<: *default_backend 193 + build: 194 + <<: *default_backend_build 195 + environment: 196 + <<: *default_backend_env_vars 197 + USE_WORKERS: true 198 + deploy: 199 + mode: replicated 200 + replicas: 3 201 + 202 + volumes: 203 + dbpg: 204 + caddy: 205 + pds: 206 + frontend: 207 + redis:
+44
docker-compose.local.yml
··· 1 + services: 2 + frontend: 3 + image: caddy:2 4 + restart: unless-stopped 5 + command: caddy run --config ${PWD}/packages/frontend/Caddyfile --adapter caddyfile 6 + ports: 7 + - 80:80 8 + - 443:443 9 + - 2019:2019 10 + volumes: 11 + - "caddy:/data" 12 + - ${PWD}/packages:${PWD}/packages 13 + 14 + db: 15 + image: postgres:17 16 + restart: unless-stopped 17 + shm_size: '2gb' 18 + environment: 19 + POSTGRES_USER: postgres 20 + POSTGRES_PASSWORD: root 21 + POSTGRES_DB: wafrn 22 + ports: 23 + - 5432:5432 24 + volumes: 25 + - dbpg:/var/lib/postgresql/data 26 + 27 + adminer: 28 + image: adminer 29 + restart: unless-stopped 30 + 31 + redis: 32 + image: redis:7.2.4 33 + restart: unless-stopped 34 + ports: 35 + - 6379:6379 36 + volumes: 37 + - redis:/data 38 + 39 + volumes: 40 + dbpg: 41 + caddy: 42 + pds: 43 + frontend: 44 + redis:
+34
docker-compose.localBackendDebuggerDev.yml
··· 1 + # Ok this is a compose that does not include nor frontend nor backend. 2 + services: 3 + db: 4 + image: postgres:17 5 + restart: unless-stopped 6 + environment: 7 + POSTGRES_USER: root 8 + PGUSER: root 9 + POSTGRES_PASSWORD: root 10 + POSTGRES_DB: wafrn 11 + volumes: 12 + - dbpg:/var/lib/postgresql/data 13 + ports: 14 + - "5432:5432" 15 + 16 + adminer: 17 + image: adminer 18 + restart: unless-stopped 19 + ports: 20 + - "8080:8080" 21 + 22 + redis: 23 + image: redis:7.2.4 24 + restart: unless-stopped 25 + volumes: 26 + - redis:/data 27 + ports: 28 + - "8001:8001" 29 + - "8070:8070" 30 + - "6379:6379" 31 + 32 + volumes: 33 + dbpg: 34 + redis:
+33
docker-compose.localBackendDevelopment.yml
··· 1 + services: 2 + db: 3 + image: postgres:17 4 + restart: unless-stopped 5 + shm_size: '2gb' 6 + environment: 7 + POSTGRES_USER: root 8 + POSTGRES_PASSWORD: root 9 + POSTGRES_DB: wafrn 10 + volumes: 11 + - dbpg:/var/lib/postgresql/data 12 + ports: 13 + - "5432:5432" 14 + 15 + adminer: 16 + image: adminer 17 + restart: unless-stopped 18 + ports: 19 + - "8080:8080" 20 + 21 + redis: 22 + image: redis:7.2.4 23 + restart: unless-stopped 24 + volumes: 25 + - redis:/data 26 + ports: 27 + - "8001:8001" 28 + - "8070:8070" 29 + - "6379:6379" 30 + 31 + volumes: 32 + dbpg: 33 + redis:
+259
docker-compose.simple.metrics.yml
··· 1 + services: 2 + backend: &default_backend 3 + build: 4 + context: . 5 + dockerfile: packages/backend/Dockerfile 6 + depends_on: 7 + db: 8 + condition: service_healthy 9 + redis: 10 + condition: service_started 11 + frontend: 12 + condition: service_started 13 + migration: 14 + condition: service_completed_successfully 15 + restart: unless-stopped 16 + environment: 17 + NODE_ENV: production 18 + ADMIN_USER: ${ADMIN_USER} 19 + ADMIN_EMAIL: ${ADMIN_EMAIL} 20 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} 21 + JWT_SECRET: ${JWT_SECRET} 22 + DOMAIN_NAME: ${DOMAIN_NAME} 23 + 24 + CACHE_DOMAIN: ${CACHE_DOMAIN} 25 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 26 + 27 + SMTP_HOST: ${SMTP_HOST} 28 + SMTP_USER: ${SMTP_USER} 29 + SMTP_PORT: ${SMTP_PORT} 30 + SMTP_PASSWORD: ${SMTP_PASSWORD} 31 + SMTP_FROM: ${SMTP_FROM} 32 + 33 + POSTGRES_USER: ${POSTGRES_USER} 34 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 35 + POSTGRES_DBNAME: ${POSTGRES_DBNAME} 36 + 37 + WEBPUSH_EMAIL: ${WEBPUSH_EMAIL} 38 + WEBPUSH_PRIVATE: ${WEBPUSH_PRIVATE} 39 + WEBPUSH_PUBLIC: ${WEBPUSH_PUBLIC} 40 + 41 + ENABLE_BSKY: ${ENABLE_BSKY} 42 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 43 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 44 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 45 + 46 + USE_WORKERS: true 47 + LOG_SQL_QUERIES: ${LOG_SQL_QUERIES:-} 48 + UPLOAD_LIMIT: ${UPLOAD_LIMIT:-} 49 + POSTS_PER_PAGE: ${POSTS_PER_PAGE:-} 50 + LOG_LEVEL: ${LOG_LEVEL:-} 51 + BLOCKLIST_URI: ${BLOCKLIST_URI:-} 52 + FRONTEND_PATH: ${FRONTEND_PATH:-} 53 + DISABLE_REQUIRE_SEND_EMAIL: ${DISABLE_REQUIRE_SEND_EMAIL:-} 54 + BLOCKED_IPS: ${BLOCKED_IPS:-} 55 + REVIEW_REGISTRATIONS: ${REVIEW_REGISTRATIONS:-} 56 + IGNORE_BLOCK_HOSTS: ${IGNORE_BLOCK_HOSTS:-} 57 + 58 + FRONTEND_LOGO: ${FRONTEND_LOGO:-} 59 + FRONTEND_API_URL: ${FRONTEND_API_URL:-} 60 + FRONTEND_MEDIA_URL: ${FRONTEND_MEDIA_URL:-} 61 + FRONTEND_CACHE_URL: ${FRONTEND_CACHE_URL:-} 62 + FRONTEND_CACHE_BACKUP_URLS: ${FRONTEND_CACHE_BACKUP_URLS:-} 63 + FRONTEND_SHORTEN_POSTS: ${FRONTEND_SHORTEN_POSTS:-} 64 + FRONTEND_DISABLE_PWA: ${FRONTEND_DISABLE_PWA:-} 65 + FRONTEND_MAINTENANCE: ${FRONTEND_MAINTENANCE:-} 66 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 67 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 68 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 69 + 70 + FRONTEND_FQDN_URL: https://${DOMAIN_NAME} 71 + 72 + ENABLE_RAW_OUTPUT: ${ENABLE_RAW_OUTPUT:-} 73 + volumes: 74 + - ./packages/backend/uploads:/app/packages/backend/uploads 75 + - ./packages/backend/cache:/app/packages/backend/cache 76 + - frontend:/app/packages/frontend:ro 77 + 78 + migration: 79 + <<: *default_backend 80 + depends_on: 81 + db: 82 + condition: service_healthy 83 + redis: 84 + condition: service_started 85 + frontend: 86 + condition: service_started 87 + restart: no 88 + command: "npm exec tsx migrate.ts init-container" 89 + 90 + frontend: 91 + restart: unless-stopped 92 + build: 93 + context: . 94 + dockerfile: packages/frontend/Dockerfile 95 + ports: 96 + - 80:80 97 + - 443:443 98 + environment: 99 + DOMAIN_NAME: ${DOMAIN_NAME} 100 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 101 + CACHE_DOMAIN: ${CACHE_DOMAIN} 102 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 103 + ACME_EMAIL: ${ACME_EMAIL} 104 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 105 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 106 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 107 + volumes: 108 + - "caddy:/data" 109 + - "frontend:/var/www/html/frontend" 110 + - ./packages/backend/uploads:/var/www/html/uploads 111 + - ./packages/caddy:/etc/caddy/config 112 + 113 + db: 114 + build: 115 + context: monitoring/database 116 + dockerfile: Dockerfile 117 + restart: unless-stopped 118 + shm_size: '2gb' 119 + environment: 120 + POSTGRES_USER: ${POSTGRES_USER} 121 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 122 + POSTGRES_DB: ${POSTGRES_DBNAME} 123 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 124 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 125 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 126 + volumes: 127 + - dbpg:/var/lib/postgresql/data 128 + 129 + adminer: 130 + image: adminer 131 + restart: unless-stopped 132 + 133 + redis: 134 + image: redis:7.2.4 135 + restart: unless-stopped 136 + volumes: 137 + - redis:/data 138 + 139 + pds: 140 + image: ghcr.io/bluesky-social/pds:0.4 141 + restart: unless-stopped 142 + profiles: 143 + - bluesky 144 + environment: 145 + PDS_HOSTNAME: ${PDS_DOMAIN_NAME} 146 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 147 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 148 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 149 + PDS_DATA_DIRECTORY: /pds 150 + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 151 + PDS_BLOB_UPLOAD_LIMIT: 52428800 152 + PDS_DID_PLC_URL: "https://plc.directory" 153 + PDS_BSKY_APP_VIEW_URL: "https://api.bsky.app" 154 + PDS_BSKY_APP_VIEW_DID: "did:web:api.bsky.app" 155 + PDS_REPORT_SERVICE_URL: "https://mod.bsky.app" 156 + PDS_REPORT_SERVICE_DID: "did:plc:ar7c4by46qjdydhdevvrndac" 157 + PDS_CRAWLERS: "https://bsky.network, https://atproto.africa" 158 + PDS_EMAIL_SMTP_URL: "smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" 159 + PDS_EMAIL_FROM_ADDRESS: "${SMTP_FROM}" 160 + LOG_ENABLED: true 161 + volumes: 162 + - pds:/pds 163 + 164 + pds_worker: 165 + <<: *default_backend 166 + profiles: 167 + - bluesky 168 + command: "npm exec tsx atproto.ts" 169 + 170 + prometheus: 171 + restart: unless-stopped 172 + image: prom/prometheus:latest 173 + volumes: 174 + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml 175 + - prometheus_data:/prometheus 176 + command: 177 + - '--config.file=/etc/prometheus/prometheus.yml' 178 + - '--storage.tsdb.path=/prometheus' 179 + - '--web.console.libraries=/usr/share/prometheus/console_libraries' 180 + - '--web.console.templates=/usr/share/prometheus/consoles' 181 + 182 + cadvisor: 183 + restart: unless-stopped 184 + image: gcr.io/cadvisor/cadvisor:latest 185 + command: 186 + - '-port=8081' 187 + environment: 188 + CADVISOR_HEALTHCHECK_URL: http://localhost:8081/healthz 189 + volumes: 190 + - /:/rootfs:ro 191 + - /var/run:/var/run:rw 192 + - /sys:/sys:ro 193 + - /var/lib/docker/:/var/lib/docker:ro 194 + 195 + node-exporter: 196 + restart: unless-stopped 197 + image: prom/node-exporter:latest 198 + volumes: 199 + - /proc:/host/proc:ro 200 + - /sys:/host/sys:ro 201 + - /:/rootfs:ro 202 + command: 203 + - '--path.procfs=/host/proc' 204 + - '--path.sysfs=/host/sys' 205 + - '--collector.filesystem.ignored-mount-points="^/(sys|proc|dev|host|etc)($$|/)"' 206 + 207 + grafana: 208 + build: 209 + context: monitoring/grafana 210 + dockerfile: Dockerfile 211 + volumes: 212 + - grafana_data:/var/lib/grafana 213 + restart: unless-stopped 214 + environment: 215 + GF_SERVER_HTTP_PORT: 2345 216 + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} 217 + GF_USERS_ALLOW_SIGN_UP: false 218 + 219 + GF_SMTP_ENABLED: true 220 + GF_SMTP_HOST: ${SMTP_HOST}:${SMTP_PORT} 221 + GF_SMTP_FROM_ADDRESS: ${SMTP_FROM} 222 + GF_SERVER_DOMAIN: ${DOMAIN_NAME} 223 + GF_SMTP_FROM_NAME: ${SMTP_FROM} 224 + GF_SMTP_USER: "${SMTP_USER}" 225 + GF_SMTP_PASSWORD: "${SMTP_PASSWORD}" 226 + 227 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 228 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 229 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 230 + 231 + pgwatch: 232 + build: 233 + context: monitoring/pgwatch 234 + dockerfile: Dockerfile 235 + args: 236 + POSTGRES_USER: ${POSTGRES_USER} 237 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 238 + POSTGRES_DB: ${POSTGRES_DBNAME} 239 + restart: unless-stopped 240 + environment: 241 + POSTGRES_METRICS_USER: ${POSTGRES_METRICS_USER} 242 + POSTGRES_METRICS_PASSWORD: ${POSTGRES_METRICS_PASSWORD} 243 + POSTGRES_METRICS_DBNAME: ${POSTGRES_METRICS_DBNAME} 244 + command: 245 + - "--web-disable=all" 246 + - "--sources=/sources.yaml" 247 + - "--sink=postgresql://${POSTGRES_METRICS_USER}:${POSTGRES_METRICS_PASSWORD}@db:5432/${POSTGRES_METRICS_DBNAME}" 248 + depends_on: 249 + db: 250 + condition: service_healthy 251 + 252 + volumes: 253 + dbpg: 254 + caddy: 255 + pds: 256 + frontend: 257 + redis: 258 + prometheus_data: 259 + grafana_data:
+170
docker-compose.simple.yml
··· 1 + services: 2 + backend: &default_backend 3 + build: 4 + context: . 5 + dockerfile: packages/backend/Dockerfile 6 + depends_on: 7 + db: 8 + condition: service_healthy 9 + redis: 10 + condition: service_started 11 + frontend: 12 + condition: service_started 13 + migration: 14 + condition: service_completed_successfully 15 + restart: unless-stopped 16 + environment: 17 + NODE_ENV: production 18 + ADMIN_USER: ${ADMIN_USER} 19 + ADMIN_EMAIL: ${ADMIN_EMAIL} 20 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} 21 + JWT_SECRET: ${JWT_SECRET} 22 + DOMAIN_NAME: ${DOMAIN_NAME} 23 + 24 + CACHE_DOMAIN: ${CACHE_DOMAIN} 25 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 26 + 27 + SMTP_HOST: ${SMTP_HOST} 28 + SMTP_USER: ${SMTP_USER} 29 + SMTP_PORT: ${SMTP_PORT} 30 + SMTP_PASSWORD: ${SMTP_PASSWORD} 31 + SMTP_FROM: ${SMTP_FROM} 32 + 33 + POSTGRES_USER: ${POSTGRES_USER} 34 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 35 + POSTGRES_DBNAME: ${POSTGRES_DBNAME} 36 + 37 + WEBPUSH_EMAIL: ${WEBPUSH_EMAIL} 38 + WEBPUSH_PRIVATE: ${WEBPUSH_PRIVATE} 39 + WEBPUSH_PUBLIC: ${WEBPUSH_PUBLIC} 40 + 41 + ENABLE_BSKY: ${ENABLE_BSKY} 42 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 43 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 44 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 45 + 46 + USE_WORKERS: true 47 + LOG_SQL_QUERIES: ${LOG_SQL_QUERIES:-} 48 + UPLOAD_LIMIT: ${UPLOAD_LIMIT:-} 49 + POSTS_PER_PAGE: ${POSTS_PER_PAGE:-} 50 + LOG_LEVEL: ${LOG_LEVEL:-} 51 + BLOCKLIST_URI: ${BLOCKLIST_URI:-} 52 + FRONTEND_PATH: ${FRONTEND_PATH:-} 53 + DISABLE_REQUIRE_SEND_EMAIL: ${DISABLE_REQUIRE_SEND_EMAIL:-} 54 + BLOCKED_IPS: ${BLOCKED_IPS:-} 55 + REVIEW_REGISTRATIONS: ${REVIEW_REGISTRATIONS:-} 56 + IGNORE_BLOCK_HOSTS: ${IGNORE_BLOCK_HOSTS:-} 57 + 58 + FRONTEND_LOGO: ${FRONTEND_LOGO:-} 59 + FRONTEND_API_URL: ${FRONTEND_API_URL:-} 60 + FRONTEND_MEDIA_URL: ${FRONTEND_MEDIA_URL:-} 61 + FRONTEND_CACHE_URL: ${FRONTEND_CACHE_URL:-} 62 + FRONTEND_CACHE_BACKUP_URLS: ${FRONTEND_CACHE_BACKUP_URLS:-} 63 + FRONTEND_SHORTEN_POSTS: ${FRONTEND_SHORTEN_POSTS:-} 64 + FRONTEND_DISABLE_PWA: ${FRONTEND_DISABLE_PWA:-} 65 + FRONTEND_MAINTENANCE: ${FRONTEND_MAINTENANCE:-} 66 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 67 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 68 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 69 + 70 + FRONTEND_FQDN_URL: https://${DOMAIN_NAME} 71 + 72 + ENABLE_RAW_OUTPUT: ${ENABLE_RAW_OUTPUT:-} 73 + volumes: 74 + - ./packages/backend/uploads:/app/packages/backend/uploads 75 + - ./packages/backend/cache:/app/packages/backend/cache 76 + - frontend:/app/packages/frontend:ro 77 + 78 + migration: 79 + <<: *default_backend 80 + depends_on: 81 + db: 82 + condition: service_started 83 + redis: 84 + condition: service_started 85 + frontend: 86 + condition: service_started 87 + restart: no 88 + command: "npm exec tsx migrate.ts init-container" 89 + 90 + frontend: 91 + restart: unless-stopped 92 + build: 93 + context: . 94 + dockerfile: packages/frontend/Dockerfile 95 + ports: 96 + - 80:80 97 + - 443:443 98 + environment: 99 + DOMAIN_NAME: ${DOMAIN_NAME} 100 + PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 101 + CACHE_DOMAIN: ${CACHE_DOMAIN} 102 + MEDIA_DOMAIN: ${MEDIA_DOMAIN} 103 + ACME_EMAIL: ${ACME_EMAIL} 104 + FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 105 + FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 106 + FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 107 + volumes: 108 + - "caddy:/data" 109 + - "frontend:/var/www/html/frontend" 110 + - ./packages/backend/uploads:/var/www/html/uploads 111 + - ./packages/caddy:/etc/caddy/config 112 + 113 + db: 114 + image: postgres:17 115 + restart: unless-stopped 116 + shm_size: '2gb' 117 + environment: 118 + POSTGRES_USER: ${POSTGRES_USER} 119 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 120 + POSTGRES_DB: ${POSTGRES_DBNAME} 121 + volumes: 122 + - dbpg:/var/lib/postgresql/data 123 + 124 + adminer: 125 + image: adminer 126 + restart: unless-stopped 127 + 128 + redis: 129 + image: redis:7.2.4 130 + restart: unless-stopped 131 + volumes: 132 + - redis:/data 133 + 134 + pds: 135 + image: ghcr.io/bluesky-social/pds:0.4 136 + restart: unless-stopped 137 + profiles: 138 + - bluesky 139 + environment: 140 + PDS_HOSTNAME: ${PDS_DOMAIN_NAME} 141 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 142 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 143 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 144 + PDS_DATA_DIRECTORY: /pds 145 + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 146 + PDS_BLOB_UPLOAD_LIMIT: 52428800 147 + PDS_DID_PLC_URL: "https://plc.directory" 148 + PDS_BSKY_APP_VIEW_URL: "https://api.bsky.app" 149 + PDS_BSKY_APP_VIEW_DID: "did:web:api.bsky.app" 150 + PDS_REPORT_SERVICE_URL: "https://mod.bsky.app" 151 + PDS_REPORT_SERVICE_DID: "did:plc:ar7c4by46qjdydhdevvrndac" 152 + PDS_CRAWLERS: "https://bsky.network, https://atproto.africa" 153 + PDS_EMAIL_SMTP_URL: "smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" 154 + PDS_EMAIL_FROM_ADDRESS: "${SMTP_FROM}" 155 + LOG_ENABLED: true 156 + volumes: 157 + - pds:/pds 158 + 159 + pds_worker: 160 + <<: *default_backend 161 + profiles: 162 + - bluesky 163 + command: "npm exec tsx atproto.ts" 164 + 165 + volumes: 166 + dbpg: 167 + caddy: 168 + pds: 169 + frontend: 170 + redis:
-174
docker-compose.yml
··· 1 - services: 2 - backend: &default_backend 3 - build: 4 - context: . 5 - dockerfile: packages/backend/Dockerfile 6 - depends_on: 7 - db: 8 - condition: service_started 9 - redis: 10 - condition: service_started 11 - frontend: 12 - condition: service_started 13 - migration: 14 - condition: service_completed_successfully 15 - restart: unless-stopped 16 - environment: 17 - NODE_ENV: production 18 - ADMIN_USER: ${ADMIN_USER} 19 - ADMIN_EMAIL: ${ADMIN_EMAIL} 20 - ADMIN_PASSWORD: ${ADMIN_PASSWORD} 21 - JWT_SECRET: ${JWT_SECRET} 22 - DOMAIN_NAME: ${DOMAIN_NAME} 23 - 24 - CACHE_DOMAIN: ${CACHE_DOMAIN} 25 - MEDIA_DOMAIN: ${MEDIA_DOMAIN} 26 - 27 - SMTP_HOST: ${SMTP_HOST} 28 - SMTP_USER: ${SMTP_USER} 29 - SMTP_PORT: ${SMTP_PORT} 30 - SMTP_PASSWORD: ${SMTP_PASSWORD} 31 - SMTP_FROM: ${SMTP_FROM} 32 - 33 - POSTGRES_USER: ${POSTGRES_USER} 34 - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 35 - POSTGRES_DBNAME: ${POSTGRES_DBNAME} 36 - 37 - WEBPUSH_EMAIL: ${WEBPUSH_EMAIL} 38 - WEBPUSH_PRIVATE: ${WEBPUSH_PRIVATE} 39 - WEBPUSH_PUBLIC: ${WEBPUSH_PUBLIC} 40 - 41 - ENABLE_BSKY: ${ENABLE_BSKY} 42 - PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 43 - PDS_JWT_SECRET: ${PDS_JWT_SECRET} 44 - PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 45 - 46 - USE_WORKERS: true 47 - LOG_SQL_QUERIES: ${LOG_SQL_QUERIES:-} 48 - UPLOAD_LIMIT: ${UPLOAD_LIMIT:-} 49 - POSTS_PER_PAGE: ${POSTS_PER_PAGE:-} 50 - LOG_LEVEL: ${LOG_LEVEL:-} 51 - BLOCKLIST_URI: ${BLOCKLIST_URI:-} 52 - FRONTEND_PATH: ${FRONTEND_PATH:-} 53 - DISABLE_REQUIRE_SEND_EMAIL: ${DISABLE_REQUIRE_SEND_EMAIL:-} 54 - BLOCKED_IPS: ${BLOCKED_IPS:-} 55 - REVIEW_REGISTRATIONS: ${REVIEW_REGISTRATIONS:-} 56 - IGNORE_BLOCK_HOSTS: ${IGNORE_BLOCK_HOSTS:-} 57 - 58 - FRONTEND_LOGO: ${FRONTEND_LOGO:-} 59 - FRONTEND_API_URL: ${FRONTEND_API_URL:-} 60 - FRONTEND_MEDIA_URL: ${FRONTEND_MEDIA_URL:-} 61 - FRONTEND_CACHE_URL: ${FRONTEND_CACHE_URL:-} 62 - FRONTEND_CACHE_BACKUP_URLS: ${FRONTEND_CACHE_BACKUP_URLS:-} 63 - FRONTEND_SHORTEN_POSTS: ${FRONTEND_SHORTEN_POSTS:-} 64 - FRONTEND_DISABLE_PWA: ${FRONTEND_DISABLE_PWA:-} 65 - FRONTEND_MAINTENANCE: ${FRONTEND_MAINTENANCE:-} 66 - FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 67 - FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 68 - FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 69 - 70 - FRONTEND_FQDN_URL: https://${DOMAIN_NAME} 71 - 72 - ENABLE_RAW_OUTPUT: ${ENABLE_RAW_OUTPUT:-} 73 - volumes: 74 - - ./packages/backend/uploads:/app/packages/backend/uploads 75 - - ./packages/backend/cache:/app/packages/backend/cache 76 - - frontend:/app/packages/frontend:ro 77 - 78 - migration: 79 - <<: *default_backend 80 - depends_on: 81 - db: 82 - condition: service_started 83 - redis: 84 - condition: service_started 85 - frontend: 86 - condition: service_started 87 - restart: no 88 - command: "npm exec tsx migrate.ts init-container" 89 - 90 - frontend: 91 - restart: unless-stopped 92 - build: 93 - context: . 94 - dockerfile: packages/frontend/Dockerfile 95 - ports: 96 - - "127.0.0.1:9237:9237" 97 - - "127.0.0.1:9238:9238" 98 - - "127.0.0.1:9239:9239" 99 - - "127.0.0.1:9240:9240" 100 - environment: 101 - DOMAIN_NAME: ${DOMAIN_NAME} 102 - PDS_DOMAIN_NAME: ${PDS_DOMAIN_NAME} 103 - CACHE_DOMAIN: ${CACHE_DOMAIN} 104 - MEDIA_DOMAIN: ${MEDIA_DOMAIN} 105 - ACME_EMAIL: ${ACME_EMAIL} 106 - FRONTEND_SHORT_TITLE: ${FRONTEND_SHORT_TITLE:-} 107 - FRONTEND_LONG_TITLE: ${FRONTEND_LONG_TITLE:-} 108 - FRONTEND_DESCRIPTION: ${FRONTEND_DESCRIPTION:-} 109 - volumes: 110 - - "caddy:/data" 111 - - "frontend:/var/www/html/frontend" 112 - - ./packages/backend/uploads:/var/www/html/uploads 113 - - ./packages/caddy:/etc/caddy/config 114 - 115 - db: 116 - image: postgres:17 117 - restart: unless-stopped 118 - shm_size: '2gb' 119 - environment: 120 - POSTGRES_USER: ${POSTGRES_USER} 121 - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 122 - POSTGRES_DB: ${POSTGRES_DBNAME} 123 - volumes: 124 - - dbpg:/var/lib/postgresql/data 125 - 126 - adminer: 127 - image: adminer 128 - restart: unless-stopped 129 - 130 - redis: 131 - image: redis:7.2.4 132 - restart: unless-stopped 133 - volumes: 134 - - redis:/data 135 - 136 - pds: 137 - image: ghcr.io/bluesky-social/pds:0.4 138 - restart: unless-stopped 139 - profiles: 140 - - bluesky 141 - environment: 142 - PDS_HOSTNAME: ${PDS_DOMAIN_NAME} 143 - PDS_JWT_SECRET: ${PDS_JWT_SECRET} 144 - PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 145 - PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 146 - PDS_DATA_DIRECTORY: /pds 147 - PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 148 - PDS_BLOB_UPLOAD_LIMIT: 52428800 149 - PDS_DID_PLC_URL: "https://plc.directory" 150 - PDS_BSKY_APP_VIEW_URL: "https://api.bsky.app" 151 - PDS_BSKY_APP_VIEW_DID: "did:web:api.bsky.app" 152 - PDS_REPORT_SERVICE_URL: "https://mod.bsky.app" 153 - PDS_REPORT_SERVICE_DID: "did:plc:ar7c4by46qjdydhdevvrndac" 154 - PDS_CRAWLERS: "https://bsky.network, https://atproto.africa" 155 - PDS_EMAIL_SMTP_URL: "smtps://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" 156 - PDS_EMAIL_FROM_ADDRESS: "${SMTP_FROM}" 157 - LOG_ENABLED: true 158 - ports: 159 - - "127.0.0.1:3001:3000" 160 - volumes: 161 - - pds:/pds 162 - 163 - pds_worker: 164 - <<: *default_backend 165 - profiles: 166 - - bluesky 167 - command: "npm exec tsx atproto.ts" 168 - 169 - volumes: 170 - dbpg: 171 - caddy: 172 - pds: 173 - frontend: 174 - redis:
-37
docs/openid.md
··· 1 - # Setting up OpenID Connect (OIDC) authentication 2 - 3 - If you are handling multiple services aside from Wafrn, you can implement something called an identity provider (or an IdP) which is a service that provides a central account to log in on multiple services. 4 - 5 - Linking it on Wafrn is possible, if your provider supports the OpenID Connect specification. Implementing this shouldn't have any breaking changes on your instance. Signing up using OpenID is not implemented due to security issues, but it will prefill your email and username on the register screen. 6 - 7 - Here's how you can implement OIDC auth on your instance: 8 - 9 - 1. Create a authentication client on your identity provider of choice. I will use Keycloak on this guide but you can use anything else (like Authentik, Authelia, etc). 10 - 11 - 1. Set your client type to OpenID Connect (if possible) 12 - 13 - 2. Set the Client ID to anything you want. Take note of that ID, we will need that later. 14 - 15 - 3. Set the client name to 'Wafrn'. You can change that if you like, that will be the name shown if someone authenticates to your Wafrn instance for the first time. Optionally, set the description of it. 16 - 17 - 4. Enable both client authentication and authorization, and don't touch anything else. 18 - 19 - 5. Set the root URL to your Wafrn instance's homepage (e.g. `https://app.wafrn.net/`), and set the home URL with the same thing. 20 - 21 - 6. Set the redirect URL to `https://wafrn.example/api/login/oidc/callback*`. 22 - 23 - 7. Copy the provided Client secret. We will need that. 24 - 25 - 2. In your .env file, edit these values: 26 - 27 - - `OIDC_ENABLED` to `true` 28 - 29 - - `OIDC_ISSUER` to your IdP's issuer URL 30 - 31 - - `OIDC_CLIENT_ID` is your specified client ID 32 - 33 - - `OIDC_CLIENT_SECRET` to the provided client secret 34 - 35 - - `OIDC_AUTH_NAME` to the name you want to call your auth provider 36 - 37 - 3. Now, restart the container. To take effect easily, you should clear your browser local storage by running `localStorage.clear()` in your browser console.
+1 -1
install/bsky/add-insert-code.sh
··· 6 6 7 7 source "${SCRIPT_DIR}/../../.env" 8 8 9 - NEW_CODE=$(curl -s --header "Content-Type: application/json" -d '{"useCount":999999}' http://admin:${PDS_ADMIN_PASSWORD}@localhost:3001/xrpc/com.atproto.server.createInviteCode | jq --raw-output '.code') 9 + NEW_CODE=$(docker exec wafrn-pds-1 wget -q -O - --header "Content-Type: application/json" --post-data '{"useCount":999999}' http://admin:${PDS_ADMIN_PASSWORD}@localhost:3000/xrpc/com.atproto.server.createInviteCode | jq --raw-output '.code') 10 10 11 11 echo Adding code ${NEW_CODE} 12 12
+2 -2
install/bsky/create-admin.sh
··· 10 10 11 11 echo Generating new invite code 12 12 13 - NEW_CODE=$(curl -s -d '{"useCount":1}' --header "Content-Type: application/json" http://admin:${PDS_ADMIN_PASSWORD}@localhost:3001/xrpc/com.atproto.server.createInviteCode | jq --raw-output '.code') 13 + NEW_CODE=$(docker exec wafrn-pds-1 wget -q -O - --header "Content-Type: application/json" --post-data '{"useCount":1}' http://admin:${PDS_ADMIN_PASSWORD}@localhost:3000/xrpc/com.atproto.server.createInviteCode | jq --raw-output '.code') 14 14 15 15 echo Invite code genrated: ${NEW_CODE} 16 16 ··· 20 20 21 21 echo Creating admin account 22 22 23 - RESULT=$(curl -s -d "{\"email\":\"${ADMIN_USER}@${DOMAIN_NAME}\", \"handle\":\"${NEW_USERNAME}.${PDS_DOMAIN_NAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${NEW_CODE}\"}" --header "Content-Type: application/json" http://localhost:3001/xrpc/com.atproto.server.createAccount) 23 + RESULT=$(docker exec wafrn-pds-1 wget -q -O - --header "Content-Type: application/json" --post-data "{\"email\":\"${ADMIN_USER}@${DOMAIN_NAME}\", \"handle\":\"${NEW_USERNAME}.${PDS_DOMAIN_NAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${NEW_CODE}\"}" http://admin:${PDS_ADMIN_PASSWORD}@localhost:3000/xrpc/com.atproto.server.createAccount) 24 24 25 25 echo $RESULT 26 26
+1 -1
install/installer.sh
··· 30 30 echo "Ok now we need an email for the administrator user" 31 31 read ADMIN_EMAIL 32 32 echo 33 - echo "We now need a handle for your administrator user. If this will be a personal, single-user instance then you can enter the username you wish to use as your main." 33 + echo "We now need a handle for your administrator user. If this will be a personal single user instance, we still recommend creating an admin account for bluesky reasons" 34 34 echo "Otherwise 'admin' is a good choice. You can also have a separate 'admin' and personal account as well." 35 35 read ADMIN_USER 36 36 echo
logs/.gitkeep

This is a binary file and will not be displayed.

+4 -37
package-lock.json
··· 1 1 { 2 2 "name": "wafrn", 3 - "version": "2025.10.03-DEV+JBC", 3 + "version": "2025.10.03-DEV", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "wafrn", 9 - "version": "2025.10.03-DEV+JBC", 9 + "version": "2025.10.03-DEV", 10 10 "license": "AGPL-3.0-or-later", 11 11 "workspaces": [ 12 12 "packages/frontend", ··· 14 14 ], 15 15 "dependencies": { 16 16 "cheerio": "^1.1.0", 17 - "openid-client": "^6.8.1", 18 17 "tsx": "^4.19.1" 19 18 }, 20 19 "devDependencies": { ··· 16674 16673 "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", 16675 16674 "license": "MIT" 16676 16675 }, 16677 - "node_modules/jose": { 16678 - "version": "6.1.0", 16679 - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", 16680 - "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", 16681 - "license": "MIT", 16682 - "funding": { 16683 - "url": "https://github.com/sponsors/panva" 16684 - } 16685 - }, 16686 16676 "node_modules/joycon": { 16687 16677 "version": "3.1.1", 16688 16678 "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", ··· 19463 19453 "@angular/core": "^20.0.0" 19464 19454 } 19465 19455 }, 19466 - "node_modules/oauth4webapi": { 19467 - "version": "3.8.2", 19468 - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.2.tgz", 19469 - "integrity": "sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==", 19470 - "license": "MIT", 19471 - "funding": { 19472 - "url": "https://github.com/sponsors/panva" 19473 - } 19474 - }, 19475 19456 "node_modules/object-assign": { 19476 19457 "version": "4.1.1", 19477 19458 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", ··· 19581 19562 }, 19582 19563 "funding": { 19583 19564 "url": "https://github.com/sponsors/sindresorhus" 19584 - } 19585 - }, 19586 - "node_modules/openid-client": { 19587 - "version": "6.8.1", 19588 - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", 19589 - "integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==", 19590 - "license": "MIT", 19591 - "dependencies": { 19592 - "jose": "^6.1.0", 19593 - "oauth4webapi": "^3.8.2" 19594 - }, 19595 - "funding": { 19596 - "url": "https://github.com/sponsors/panva" 19597 19565 } 19598 19566 }, 19599 19567 "node_modules/optionator": { ··· 26039 26007 }, 26040 26008 "packages/backend": { 26041 26009 "name": "wafrn-backend", 26042 - "version": "2025.10.03-DEV+JBC", 26010 + "version": "2025.10.03-DEV", 26043 26011 "license": "MIT", 26044 26012 "dependencies": { 26045 26013 "@atcute/bluesky-richtext-builder": "^1.0.2", ··· 26111 26079 "node-cron": "^4.2.1", 26112 26080 "node-stream-zip": "^1.15.0", 26113 26081 "nodemailer": "^6.7.2", 26114 - "openid-client": "^6.8.1", 26115 26082 "otpauth": "^9.4.0", 26116 26083 "pg": "^8.11.0", 26117 26084 "pg-hstore": "^2.3.4", ··· 26384 26351 }, 26385 26352 "packages/frontend": { 26386 26353 "name": "wafrn", 26387 - "version": "2025.10.03-DEV+JBC", 26354 + "version": "2025.10.03-DEV", 26388 26355 "dependencies": { 26389 26356 "@angular-eslint/schematics": "^20.0.0", 26390 26357 "@angular/animations": "^20.0.2",
+2 -3
package.json
··· 1 1 { 2 2 "name": "wafrn", 3 - "version": "2025.10.03-DEV+JBC", 3 + "version": "2025.10.03-DEV", 4 4 "description": "wafrn", 5 5 "main": "index.ts", 6 - "scripts": { 6 + "scripts": { 7 7 "full:upgrade": "git pull && pm2 restart all && npm run frontend:deploy", 8 8 "backend:prettier-format": "cd packages/backend && prettier --config .prettierrc '**/*.ts' --write", 9 9 "backend:develop": "cd packages/backend && tsx watch index.ts", ··· 50 50 }, 51 51 "dependencies": { 52 52 "cheerio": "^1.1.0", 53 - "openid-client": "^6.8.1", 54 53 "tsx": "^4.19.1" 55 54 } 56 55 }
+1
packages/backend/.gitignore
··· 12 12 logs 13 13 /cache 14 14 environment.prod.ts 15 + environment.dev.ts 15 16 /build 16 17 17 18 .env
+30 -437
packages/backend/atproto/utils/getAtProtoThread.ts
··· 1 1 // returns the post id 2 2 import { getAtProtoSession } from './getAtProtoSession.js' 3 3 import { QueryParams } from '@atproto/sync/dist/firehose/lexicons.js' 4 - import { Media, Notification, Post, PostMentionsUserRelation, PostTag, Quotes, User } from '../../models/index.js' 4 + import { EmojiReaction, Media, Notification, Post, PostAncestor, PostMentionsUserRelation, PostReport, PostTag, QuestionPoll, Quotes, RemoteUserPostView, SilencedPost, User, UserBitesPostRelation, UserBookmarkedPosts, UserLikesPostRelations } from '../../models/index.js' 5 5 import { Model, Op } from 'sequelize' 6 6 import { PostView, ThreadViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs.js' 7 7 import { getAtprotoUser } from './getAtprotoUser.js' ··· 19 19 import { MediaAttributes } from '../../models/media.js' 20 20 import { getAdminAtprotoSession } from '../../utils/atproto/getAdminAtprotoSession.js' 21 21 import { getPostThreadRecursive } from '../../utils/activitypub/getPostThreadRecursive.js' 22 + import { Queue, QueueEvents } from 'bullmq' 22 23 23 24 const markdownConverter = new showdown.Converter({ 24 25 simplifiedAutoLink: true, ··· 29 30 emoji: true 30 31 }) 31 32 32 - const adminUser = User.findOne({ 33 - where: { 34 - url: completeEnvironment.adminUser 33 + const processPostQueue = new Queue<{ post: PostView, parentId?: string, forceUpdate?: boolean }, string | undefined>('processSinglePost', { 34 + connection: completeEnvironment.bullmqConnection, 35 + defaultJobOptions: { 36 + removeOnComplete: true, 37 + attempts: 6, 38 + backoff: { 39 + type: 'exponential', 40 + delay: 25000 41 + }, 42 + removeOnFail: false 35 43 } 36 44 }) 45 + processPostQueue.setMaxListeners(0); 46 + 47 + const processPostQueueEvents = new QueueEvents('processSinglePost', { 48 + connection: completeEnvironment.bullmqConnection, 49 + }); 50 + processPostQueueEvents.setMaxListeners(0); 51 + 52 + async function processSinglePost( 53 + post: PostView, 54 + parentId?: string, 55 + forceUpdate?: boolean 56 + ): Promise<string | undefined> { 57 + const job = await processPostQueue.add('processSinglePost', { post, parentId, forceUpdate }) 58 + const finished = await job.waitUntilFinished(processPostQueueEvents, 60000).catch((err) => { 59 + logger.debug(err, "Error occured while getting atproto post") 60 + }); 61 + return finished ?? undefined 62 + } 37 63 38 64 async function getAtProtoThread( 39 65 uri: string, ··· 100 126 return await processSinglePost(thread.post, parentId) 101 127 } 102 128 103 - async function processSinglePost( 104 - post: PostView, 105 - parentId?: string, 106 - forceUpdate?: boolean 107 - ): Promise<string | undefined> { 108 - if (!post || !completeEnvironment.enableBsky) { 109 - return undefined 110 - } 111 - if (!forceUpdate) { 112 - const existingPost = await Post.findOne({ 113 - where: { 114 - bskyUri: post.uri 115 - } 116 - }) 117 - if (existingPost) { 118 - return existingPost.id 119 - } 120 - } 121 - let postCreator: User | undefined 122 - try { 123 - postCreator = await getAtprotoUser(post.author.did, (await adminUser) as User, post.author) 124 - } catch (error) { 125 - logger.debug({ 126 - message: `Problem obtaining user from post`, 127 - post, 128 - parentId, 129 - forceUpdate, 130 - error 131 - }) 132 - } 133 - let verifiedFedi: string | undefined; 134 - if ('fediverseUrl' in post.record || 'bridgyOriginalUrl' in post.record) { 135 - const record = post.record as any 136 - if ('bridgyOriginalUrl' in post.record) { 137 - const res = await fetch("https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc" + `?identifier=${post.author.did}`); 138 - if (res.ok) { 139 - const json = await res.json() as { pds: string }; 140 - if (json.pds.replace(/^https?:\/\//, '') === 'atproto.brid.gy') { 141 - // if user is on bridgy pds, verify it 142 - verifiedFedi = post.record.bridgyOriginalUrl as string; 143 - } 144 - } 145 - } else { 146 - // prob wafrn post, but lets verify it 147 - const res = await fetch(post.record.fediverseUrl as string, { 148 - headers: { 149 - 'Accept': 'application/json' 150 - } 151 - }) 152 - if (res.ok) { 153 - const json = await res.json() as { object: { bskyCid: string } }; 154 - if (json.object.bskyCid === post.cid) { 155 - verifiedFedi = post.record.fediverseUrl as string; 156 - } 157 - } 158 - } 159 - } 160 - if (verifiedFedi) { 161 - try { 162 - const remotePost = await getPostThreadRecursive(postCreator, verifiedFedi); 163 - if (remotePost) { 164 - await getPostThreadRecursive(postCreator, verifiedFedi, undefined, remotePost.id) 165 - remotePost.bskyCid = post.cid; 166 - remotePost.bskyUri = post.uri; 167 - Post.upsert(remotePost, { 168 - conflictFields: ['userId', 'bskyCid', 'bskyUri'] 169 - }) 170 - return remotePost.id 171 - } 172 - } catch (error) { 173 - logger.debug({ 174 - message: `Error in obtaining fedi post ${verifiedFedi}`, 175 - error 176 - }) 177 - } 178 - } 179 - if (!postCreator || !post) { 180 - const usr = postCreator ? postCreator : await User.findOne({ where: { url: completeEnvironment.deletedUser } }) 181 - 182 - const invalidPost = await Post.create({ 183 - userId: usr?.id, 184 - content: `Failed to get atproto post`, 185 - parentId: parentId, 186 - isDeleted: true, 187 - createdAt: new Date(0), 188 - updatedAt: new Date(0) 189 - }) 190 - return invalidPost.id 191 - } 192 - if (postCreator) { 193 - const medias = getPostMedias(post) 194 - let tags: string[] = [] 195 - let mentions: string[] = [] 196 - let record = post.record as any 197 - let postText = record.text 198 - let federatedWoot = false 199 - if (record.fullText || record.bridgyOriginalText) { 200 - federatedWoot = true 201 - tags = record.fullTags?.split('\n').filter((x: string) => !!x) ?? [] // also detect full tags 202 - postText = record.fullText ?? record.bridgyOriginalText 203 - } 204 - if (record.facets && record.facets.length > 0 && !federatedWoot) { 205 - // lets get mentions 206 - const mentionedDids = record.facets 207 - .flatMap((elem: any) => elem.features) 208 - .map((elem: any) => elem.did) 209 - .filter((elem: any) => elem) 210 - if (mentionedDids && mentionedDids.length > 0) { 211 - const mentionedUsers = await User.findAll({ 212 - where: { 213 - bskyDid: { 214 - [Op.in]: mentionedDids 215 - } 216 - } 217 - }) 218 - mentions = mentionedUsers.map((elem) => elem.id) 219 - } 220 - 221 - const rt = new RichText({ 222 - text: postText, 223 - facets: record.facets 224 - }) 225 - let text = '' 226 - 227 - for (const segment of rt.segments()) { 228 - if (segment.isLink()) { 229 - const href = segment.link?.uri 230 - text += `<a href="${href}" target="_blank">${href}</a>` 231 - } else if (segment.isMention()) { 232 - const href = `${completeEnvironment.frontendUrl}/blog/${segment.mention?.did}` 233 - text += `<a href="${href}" target="_blank">${segment.text}</a>` 234 - } else if (segment.isTag()) { 235 - const href = `${completeEnvironment.frontendUrl}/dashboard/search/${segment.text.substring(1)}` 236 - text += `<a href="${href}" target="_blank">${segment.text}</a>` 237 - tags.push(segment.text.substring(1)) 238 - } else { 239 - text += segment.text 240 - } 241 - } 242 - postText = text 243 - } 244 - if (!federatedWoot) postText = postText.replaceAll('\n', '<br>') 245 - 246 - const labels = getPostLabels(post) 247 - let cw = labels.length > 0 ? `Post is labeled as: ${labels.join(', ')}` : undefined 248 - if (!cw && postCreator.NSFW) { 249 - cw = 'This user has been marked as NSFW and the post has been labeled automatically as NSFW' 250 - } 251 - const newData = { 252 - userId: postCreator.id, 253 - bskyCid: post.cid, 254 - bskyUri: post.uri, 255 - content: postText, 256 - createdAt: new Date((post.record as any).createdAt), 257 - privacy: Privacy.Public, 258 - parentId: parentId, 259 - content_warning: cw, 260 - ...getPostInteractionLevels(post, parentId) 261 - } 262 - if (!parentId) { 263 - delete newData.parentId 264 - } 265 - 266 - if ((await getAllLocalUserIds()).includes(newData.userId) && !forceUpdate) { 267 - // dirty as hell but this should stop the duplication 268 - await wait(1500) 269 - } 270 - let [postToProcess, created] = await Post.findOrCreate({ where: { bskyUri: post.uri }, defaults: newData }) 271 - // do not update existing posts. But what if local user creates a post through bsky? then we force updte i guess 272 - if (!(await getAllLocalUserIds()).includes(postToProcess.userId) || created) { 273 - if (!created) { 274 - postToProcess.set(newData) 275 - await postToProcess.save() 276 - } 277 - if (medias) { 278 - await Media.destroy({ 279 - where: { 280 - postId: postToProcess.id 281 - } 282 - }) 283 - await Media.bulkCreate( 284 - medias.map((media: any) => { 285 - return { ...media, postId: postToProcess.id } 286 - }) 287 - ) 288 - } 289 - if (parentId) { 290 - const ancestors = await postToProcess.getAncestors({ 291 - attributes: ['userId'], 292 - where: { 293 - hierarchyLevel: { 294 - [Op.gt]: postToProcess.hierarchyLevel - 5 295 - } 296 - } 297 - }) 298 - mentions = mentions.concat(ancestors.map((elem) => elem.userId)) 299 - } 300 - mentions = [...new Set(mentions)] 301 - if (mentions.length > 0) { 302 - await Notification.destroy({ 303 - where: { 304 - notificationType: 'MENTION', 305 - postId: postToProcess.id 306 - } 307 - }) 308 - await PostMentionsUserRelation.destroy({ 309 - where: { 310 - postId: postToProcess.id 311 - } 312 - }) 313 - await bulkCreateNotifications( 314 - mentions.map((mnt) => ({ 315 - notificationType: 'MENTION', 316 - postId: postToProcess.id, 317 - notifiedUserId: mnt, 318 - userId: postToProcess.userId, 319 - createdAt: new Date(postToProcess.createdAt) 320 - })), 321 - { 322 - ignoreDuplicates: true, 323 - postContent: postText, 324 - userUrl: postCreator.url 325 - } 326 - ) 327 - await PostMentionsUserRelation.bulkCreate( 328 - mentions.map((mnt) => { 329 - return { 330 - userId: mnt, 331 - postId: postToProcess.id 332 - } 333 - }), 334 - { ignoreDuplicates: true } 335 - ) 336 - } 337 - if (tags.length > 0) { 338 - await PostTag.destroy({ 339 - where: { 340 - postId: postToProcess.id 341 - } 342 - }) 343 - await PostTag.bulkCreate( 344 - tags.map((tag) => { 345 - return { 346 - postId: postToProcess.id, 347 - tagName: tag 348 - } 349 - }) 350 - ) 351 - } 352 - const quotedPostUri = getQuotedPostUri(post) 353 - if (quotedPostUri) { 354 - const quotedPostId = await getAtProtoThread(quotedPostUri) 355 - if (quotedPostId) { 356 - const quotedPost = await Post.findByPk(quotedPostId) 357 - if (quotedPost) { 358 - await createNotification( 359 - { 360 - notificationType: 'QUOTE', 361 - notifiedUserId: quotedPost.userId, 362 - userId: postToProcess.userId, 363 - postId: postToProcess.id 364 - }, 365 - { 366 - postContent: postToProcess.content, 367 - userUrl: postCreator?.url 368 - } 369 - ) 370 - await Quotes.findOrCreate({ 371 - where: { 372 - quoterPostId: postToProcess.id, 373 - quotedPostId: quotedPostId 374 - } 375 - }) 376 - } 377 - } 378 - } 379 - } 380 - 381 - return postToProcess.id 382 - } 383 - } 384 - 385 - function getPostMedias(post: PostView) { 386 - let res: MediaAttributes[] = [] 387 - const labels = getPostLabels(post) 388 - const embed = (post.record as any).embed 389 - if (embed) { 390 - if (embed.external) { 391 - res = res.concat([ 392 - { 393 - mediaType: !embed.external.uri.startsWith('https://media.ternor.com/') ? 'text/html' : 'image/gif', 394 - description: embed.external.title, 395 - url: embed.external.uri, 396 - mediaOrder: 0, 397 - external: true 398 - } 399 - ]) 400 - } 401 - if (embed.images || embed.media) { 402 - // case with quote and gif / link preview 403 - if (embed.media?.external) { 404 - res = res.concat([ 405 - { 406 - mediaType: !embed.media.external.uri.startsWith('https://media.ternor.com/') ? 'text/html' : 'image/gif', 407 - description: embed.media.external.title, 408 - url: embed.media.external.uri, 409 - mediaOrder: 0, 410 - external: true 411 - } 412 - ]) 413 - } else { 414 - const thingToProcess = embed.images ? embed.images : embed.media.images 415 - if (thingToProcess) { 416 - const toConcat = thingToProcess.map((media: any, index: any) => { 417 - const cid = media.image.ref['$link'] ? media.image.ref['$link'] : media.image.ref.toString() 418 - const did = post.author.did 419 - return { 420 - mediaType: media.image.mimeType, 421 - description: media.alt, 422 - height: media.aspectRatio?.height, 423 - width: media.aspectRatio?.width, 424 - url: `?cid=${encodeURIComponent(cid)}&did=${encodeURIComponent(did)}`, 425 - mediaOrder: index, 426 - external: true 427 - } 428 - }) 429 - res = res.concat(toConcat) 430 - } else { 431 - logger.debug({ 432 - message: `Bsky problem getting medias on post ${post.uri}` 433 - }) 434 - } 435 - } 436 - } 437 - if (embed.video) { 438 - const video = embed.video 439 - const cid = video.ref['$link'] ? video.ref['$link'] : video.ref.toString() 440 - const did = post.author.did 441 - res = res.concat([ 442 - { 443 - mediaType: embed.video.mimeType, 444 - description: '', 445 - height: embed.aspectRatio?.height, 446 - width: embed.aspectRatio?.width, 447 - url: `?cid=${encodeURIComponent(cid)}&did=${encodeURIComponent(did)}`, 448 - mediaOrder: 0, 449 - external: true 450 - } 451 - ]) 452 - } 453 - } 454 - return res.map((m) => { 455 - return { 456 - ...m, 457 - NSFW: labels.length > 0 458 - } 459 - }) 460 - } 461 - 462 129 function getQuotedPostUri(post: PostView): string | undefined { 463 130 let res: string | undefined = undefined 464 131 const embed = (post.record as any).embed ··· 472 139 return res 473 140 } 474 141 475 - // TODO improve this so we get better nsfw messages lol 476 - function getPostLabels(post: PostView) { 477 - let labels = new Set<string>() 478 - if (post.labels) { 479 - for (const label of post.labels) { 480 - if (label.neg && labels.has(label.val)) { 481 - labels.delete(label.val) 482 - } else { 483 - labels.add(label.val) 484 - } 485 - } 486 - } 487 - return Array.from(labels) 488 - } 489 - 490 142 async function getPostThreadSafe(options: any) { 491 143 try { 492 144 const agent = await getAdminAtprotoSession() ··· 497 149 options: options, 498 150 error: error 499 151 }) 500 - } 501 - } 502 - 503 - function getPostInteractionLevels( 504 - post: PostView, 505 - parentId: string | undefined 506 - ): { 507 - replyControl: InteractionControlType 508 - likeControl: InteractionControlType 509 - reblogControl: InteractionControlType 510 - quoteControl: InteractionControlType 511 - } { 512 - let canQuote = InteractionControl.Anyone 513 - let canReply: InteractionControlType = InteractionControl.Anyone 514 - if (post.viewer && post.viewer.embeddingDisabled) { 515 - canQuote = InteractionControl.NoOne 516 - } 517 - if (parentId) { 518 - canReply = InteractionControl.SameAsOp 519 - canQuote = InteractionControl.SameAsOp 520 - } else if (post.threadgate && post.threadgate.record && (post.threadgate.record as any).allow) { 521 - const allowList = (post.threadgate.record as any).allow 522 - if (allowList.length == 0) { 523 - canReply = InteractionControl.NoOne 524 - } else { 525 - const mentiontypes: string[] = allowList 526 - .map((elem: any) => elem['$type']) 527 - .map((elem: string) => elem.split('app.bsky.feed.threadgate#')[1]) 528 - if (mentiontypes.includes('mentionRule')) { 529 - if (mentiontypes.includes('followingRule')) { 530 - canReply = mentiontypes.includes('followerRule') 531 - ? InteractionControl.FollowersFollowersAndMentioned 532 - : InteractionControl.FollowingAndMentioned 533 - } else { 534 - canReply = mentiontypes.includes('followerRule') 535 - ? InteractionControl.FollowersAndMentioned 536 - : InteractionControl.MentionedUsersOnly 537 - } 538 - } else { 539 - if (mentiontypes.includes('followingRule')) { 540 - canReply = mentiontypes.includes('followerRule') 541 - ? InteractionControl.FollowersAndFollowing 542 - : InteractionControl.Following 543 - } else { 544 - canReply = mentiontypes.includes('followerRule') ? InteractionControl.Followers : InteractionControl.NoOne 545 - } 546 - } 547 - } 548 - } 549 - 550 - if (canQuote === InteractionControl.Anyone && canReply != InteractionControl.Anyone) { 551 - canQuote = canReply 552 - } 553 - 554 - return { 555 - quoteControl: canQuote, 556 - replyControl: canReply, 557 - likeControl: InteractionControl.Anyone, 558 - reblogControl: InteractionControl.Anyone 559 152 } 560 153 } 561 154
+17 -12
packages/backend/atproto/utils/postToAtproto.ts
··· 186 186 187 187 if (token.type === 'link') { 188 188 builder.addLink(token.text, token.url) 189 - } 190 - 191 - // only add a embed if theres no embed, bsky only supports 1 embed 192 - if (token.type === 'autolink' && !('embed' in res)) { 189 + } else if (token.type === 'autolink' && medias.length === 0) { 190 + // only add a embed if theres no embed, bsky only supports 1 embed 193 191 builder.addLink(token.url, token.url) 194 192 const shasum = crypto.createHash('sha1') 195 193 shasum.update(token.url.toLowerCase()) 196 194 const urlHash = shasum.digest('hex') 197 195 let linkPreview: { url: string; title: string; description: string } | undefined = JSON.parse(await redisCache.get('linkPreviewCache:' + urlHash) ?? '{}') 198 196 if (!linkPreview?.title) { 199 - linkPreview = await getLinkPreview(token.url, { 200 - followRedirects: 'follow', 201 - headers: { 'User-Agent': completeEnvironment.instanceUrl } 202 - }) as { url: string; title: string; description: string } | undefined; 203 - await redisCache.set('linkPreviewCache:' + urlHash, JSON.stringify(linkPreview), 'EX', linkPreview ? 3600 * 24 : 300) 197 + try { 198 + linkPreview = await getLinkPreview(token.url, { 199 + followRedirects: 'follow', 200 + headers: { 'User-Agent': completeEnvironment.instanceUrl } 201 + }) as { url: string; title: string; description: string } | undefined; 202 + await redisCache.set('linkPreviewCache:' + urlHash, JSON.stringify(linkPreview), 'EX', linkPreview ? 3600 * 24 : 300) 203 + } catch (error) { 204 + logger.trace({ 205 + message: `Error obtaining link ${token.url}`, 206 + error: error 207 + }) 208 + } 209 + 204 210 } 205 211 206 212 if (linkPreview?.title) { ··· 209 215 external: { 210 216 uri: linkPreview.url, 211 217 title: linkPreview.title, 212 - description: linkPreview.description 218 + description: linkPreview.description ?? `from ${new URL(linkPreview.url).hostname}` 213 219 } 214 220 } 215 221 } 216 - } 217 - else builder.addText(token.raw) 222 + } else builder.addText(token.raw) 218 223 } 219 224 postText = builder.text 220 225
+593
packages/backend/atproto/workers/processSinglePostWorker.ts
··· 1 + import { RichText } from "@atproto/api" 2 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs.js" 3 + import { Op } from "sequelize" 4 + import { EmojiReaction, Media, Notification, Post, PostAncestor, PostMentionsUserRelation, PostReport, PostTag, QuestionPoll, Quotes, RemoteUserPostView, SilencedPost, User, UserBitesPostRelation, UserBookmarkedPosts, UserLikesPostRelations } from '../../models/index.js' 5 + import { getPostThreadRecursive } from "../../utils/activitypub/getPostThreadRecursive.js" 6 + import { completeEnvironment } from "../../utils/backendOptions.js" 7 + import { getAllLocalUserIds } from "../../utils/cacheGetters/getAllLocalUserIds.js" 8 + import { logger } from "../../utils/logger.js" 9 + import { bulkCreateNotifications, createNotification } from "../../utils/pushNotifications.js" 10 + import { wait } from "../../utils/wait.js" 11 + import { getQuotedPostUri, getAtProtoThread } from "../utils/getAtProtoThread.js" 12 + import { getAtprotoUser } from "../utils/getAtprotoUser.js" 13 + import { MediaAttributes } from "../../models/media.js" 14 + import { Privacy, InteractionControlType, InteractionControl } from "../../models/post.js" 15 + import { getAdminUser } from "../../utils/getAdminAndDeletedUser.js" 16 + import { Job } from "bullmq" 17 + 18 + const adminUser = getAdminUser(); 19 + 20 + async function processSinglePostJob(job: Job): Promise<string | undefined> { 21 + if (!job.data.post) { 22 + return undefined; 23 + } 24 + let post = await processSinglePost(job.data.post, job.data.parentId, job.data.forceUpdate) 25 + return post 26 + } 27 + 28 + async function processSinglePost( 29 + post: PostView, 30 + parentId?: string, 31 + forceUpdate?: boolean 32 + ): Promise<string | undefined> { 33 + if (!post || !completeEnvironment.enableBsky) { 34 + return undefined 35 + } 36 + if (!forceUpdate) { 37 + const existingPost = await Post.findOne({ 38 + where: { 39 + bskyUri: post.uri 40 + } 41 + }) 42 + if (existingPost) { 43 + return existingPost.id 44 + } 45 + } 46 + let postCreator: User | undefined 47 + try { 48 + postCreator = await getAtprotoUser(post.author.did, (await adminUser) as User, post.author) 49 + } catch (error) { 50 + logger.debug({ 51 + message: `Problem obtaining user from post`, 52 + post, 53 + parentId, 54 + forceUpdate, 55 + error 56 + }) 57 + } 58 + let verifiedFedi: string | undefined; 59 + if ('fediverseId' in post.record || 'bridgyOriginalUrl' in post.record) { 60 + if ('bridgyOriginalUrl' in post.record) { 61 + const res = await fetch("https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc" + `?identifier=${post.author.did}`); 62 + if (res.ok) { 63 + const json = await res.json() as { pds: string }; 64 + if (json.pds.replace(/^https?:\/\//, '') === 'atproto.brid.gy') { 65 + // if user is on bridgy pds, verify it 66 + verifiedFedi = post.record.bridgyOriginalUrl as string; 67 + logger.info({ uri: post.uri, url: post.record.bridgyOriginalUrl }, 'fedi bridged post is bridgy fed'); 68 + } 69 + } 70 + } else { 71 + // prob wafrn post, but lets verify it 72 + try { 73 + const waf = await fetch(`https://${new URL(post.record.fediverseId as string).hostname}/api/environment`) 74 + if (waf.ok) { 75 + const res = await fetch((post.record.fediverseId as string).replace('fediverse/', 'api/v2/'), { 76 + headers: { 77 + 'Accept': 'application/json' 78 + } 79 + }) 80 + if (res.ok) { 81 + const json = await res.json() as { posts: { bskyCid: string }[] }; 82 + if (json.posts[0].bskyCid === post.cid) { 83 + verifiedFedi = post.record.fediverseId as string; 84 + logger.info({ uri: post.uri, url: post.record.fediverseId }, 'fedi bridged post is wafrn'); 85 + } 86 + } 87 + } 88 + } catch (error) { 89 + logger.debug(error, `Error in obtaining fedi post ${post.record.fediverseId}`) 90 + } 91 + } 92 + } 93 + if (verifiedFedi) { 94 + try { 95 + const remotePost = await getPostThreadRecursive(postCreator, verifiedFedi); 96 + if (remotePost) { 97 + await getPostThreadRecursive(postCreator, verifiedFedi, undefined, remotePost.id) 98 + remotePost.bskyCid = post.cid; 99 + remotePost.bskyUri = post.uri; 100 + // if there's already a bsky post about 101 + // this that doesn't have any fedi urls, delete it 102 + // and prob update the things 103 + let existingPost = await Post.findOne({ 104 + where: { 105 + bskyCid: post.cid, 106 + remotePostId: null 107 + } 108 + }) 109 + if (existingPost) { 110 + // very expensive updates! but only happens when user 111 + // searches existing post that is alr on db 112 + await EmojiReaction.update({ 113 + postId: remotePost.id 114 + }, { 115 + where: { 116 + postId: existingPost.id 117 + } 118 + }) 119 + await Notification.update({ 120 + postId: remotePost.id 121 + }, { 122 + where: { 123 + postId: existingPost.id 124 + } 125 + }) 126 + await PostReport.update({ 127 + postId: remotePost.id 128 + }, { 129 + where: { 130 + postId: existingPost.id 131 + } 132 + }) 133 + try { 134 + await PostAncestor.update({ 135 + postsId: remotePost.id 136 + }, { 137 + where: { 138 + postsId: existingPost.id 139 + } 140 + }) 141 + } catch {} 142 + await QuestionPoll.update({ 143 + postId: remotePost.id 144 + }, { 145 + where: { 146 + postId: existingPost.id 147 + } 148 + }) 149 + await Quotes.update({ 150 + quoterPostId: remotePost.id 151 + }, { 152 + where: { 153 + quoterPostId: existingPost.id 154 + } 155 + }) 156 + if (!await Quotes.findOne({ 157 + where: { 158 + quotedPostId: remotePost.id 159 + } 160 + })) { 161 + await Quotes.update({ 162 + quotedPostId: remotePost.id 163 + }, { 164 + where: { 165 + quotedPostId: existingPost.id 166 + } 167 + }) 168 + } 169 + await RemoteUserPostView.update({ 170 + postId: remotePost.id 171 + }, { 172 + where: { 173 + postId: existingPost.id 174 + } 175 + }) 176 + await SilencedPost.update({ 177 + postId: remotePost.id 178 + }, { 179 + where: { 180 + postId: existingPost.id 181 + } 182 + }) 183 + await SilencedPost.update({ 184 + postId: remotePost.id 185 + }, { 186 + where: { 187 + postId: existingPost.id 188 + } 189 + }) 190 + await UserBitesPostRelation.update({ 191 + postId: remotePost.id 192 + }, { 193 + where: { 194 + postId: existingPost.id 195 + } 196 + }) 197 + await UserBookmarkedPosts.update({ 198 + postId: remotePost.id 199 + }, { 200 + where: { 201 + postId: existingPost.id 202 + } 203 + }) 204 + await UserLikesPostRelations.update({ 205 + postId: remotePost.id 206 + }, { 207 + where: { 208 + postId: existingPost.id 209 + } 210 + }) 211 + await Post.update({ 212 + parentId: remotePost.id 213 + }, { 214 + where: { 215 + parentId: existingPost.id 216 + } 217 + }) 218 + 219 + await Post.destroy({ 220 + where: { 221 + bskyCid: post.cid, 222 + remotePostId: null 223 + } 224 + }) 225 + } 226 + await remotePost.save() 227 + return remotePost.id 228 + } 229 + } catch (error) { 230 + logger.debug({ 231 + message: `Error in obtaining fedi post ${verifiedFedi}`, 232 + error 233 + }) 234 + } 235 + } 236 + if (!postCreator || !post) { 237 + const usr = postCreator ? postCreator : await User.findOne({ where: { url: completeEnvironment.deletedUser } }) 238 + 239 + const invalidPost = await Post.create({ 240 + userId: usr?.id, 241 + content: `Failed to get atproto post`, 242 + parentId: parentId, 243 + isDeleted: true, 244 + createdAt: new Date(0), 245 + updatedAt: new Date(0) 246 + }) 247 + return invalidPost.id 248 + } 249 + if (postCreator) { 250 + const medias = getPostMedias(post) 251 + let tags: string[] = [] 252 + let mentions: string[] = [] 253 + let record = post.record as any 254 + let postText = record.text 255 + let federatedWoot = false 256 + if (record.fullText || record.bridgyOriginalText) { 257 + federatedWoot = true 258 + tags = record.fullTags?.split('\n').filter((x: string) => !!x) ?? [] // also detect full tags 259 + postText = record.fullText ?? record.bridgyOriginalText 260 + } 261 + if (record.facets && record.facets.length > 0 && !federatedWoot) { 262 + // lets get mentions 263 + const mentionedDids = record.facets 264 + .flatMap((elem: any) => elem.features) 265 + .map((elem: any) => elem.did) 266 + .filter((elem: any) => elem) 267 + if (mentionedDids && mentionedDids.length > 0) { 268 + const mentionedUsers = await User.findAll({ 269 + where: { 270 + bskyDid: { 271 + [Op.in]: mentionedDids 272 + } 273 + } 274 + }) 275 + mentions = mentionedUsers.map((elem) => elem.id) 276 + } 277 + 278 + const rt = new RichText({ 279 + text: postText, 280 + facets: record.facets 281 + }) 282 + let text = '' 283 + 284 + for (const segment of rt.segments()) { 285 + if (segment.isLink()) { 286 + const href = segment.link?.uri 287 + text += `<a href="${href}" target="_blank">${href}</a>` 288 + } else if (segment.isMention()) { 289 + const href = `${completeEnvironment.frontendUrl}/blog/${segment.mention?.did}` 290 + text += `<a href="${href}" target="_blank">${segment.text}</a>` 291 + } else if (segment.isTag()) { 292 + const href = `${completeEnvironment.frontendUrl}/dashboard/search/${segment.text.substring(1)}` 293 + text += `<a href="${href}" target="_blank">${segment.text}</a>` 294 + tags.push(segment.text.substring(1)) 295 + } else { 296 + text += segment.text 297 + } 298 + } 299 + postText = text 300 + } 301 + if (!federatedWoot) postText = postText.replaceAll('\n', '<br>') 302 + 303 + const labels = getPostLabels(post) 304 + let cw = labels.length > 0 ? `Post is labeled as: ${labels.join(', ')}` : undefined 305 + if (!cw && postCreator.NSFW) { 306 + cw = 'This user has been marked as NSFW and the post has been labeled automatically as NSFW' 307 + } 308 + const newData = { 309 + userId: postCreator.id, 310 + bskyCid: post.cid, 311 + bskyUri: post.uri, 312 + content: postText, 313 + createdAt: new Date((post.record as any).createdAt), 314 + privacy: Privacy.Public, 315 + parentId: parentId, 316 + content_warning: cw, 317 + ...getPostInteractionLevels(post, parentId) 318 + } 319 + if (!parentId) { 320 + delete newData.parentId 321 + } 322 + 323 + if ((await getAllLocalUserIds()).includes(newData.userId) && !forceUpdate) { 324 + // dirty as hell but this should stop the duplication 325 + await wait(1500) 326 + } 327 + let [postToProcess, created] = await Post.findOrCreate({ where: { bskyUri: post.uri }, defaults: newData }) 328 + // do not update existing posts. But what if local user creates a post through bsky? then we force updte i guess 329 + if (!(await getAllLocalUserIds()).includes(postToProcess.userId) || created) { 330 + if (!created) { 331 + postToProcess.set(newData) 332 + await postToProcess.save() 333 + } 334 + if (medias) { 335 + await Media.destroy({ 336 + where: { 337 + postId: postToProcess.id 338 + } 339 + }) 340 + await Media.bulkCreate( 341 + medias.map((media: any) => { 342 + return { ...media, postId: postToProcess.id } 343 + }) 344 + ) 345 + } 346 + if (parentId) { 347 + const ancestors = await postToProcess.getAncestors({ 348 + attributes: ['userId'], 349 + where: { 350 + hierarchyLevel: { 351 + [Op.gt]: postToProcess.hierarchyLevel - 5 352 + } 353 + } 354 + }) 355 + mentions = mentions.concat(ancestors.map((elem) => elem.userId)) 356 + } 357 + mentions = [...new Set(mentions)] 358 + if (mentions.length > 0) { 359 + await Notification.destroy({ 360 + where: { 361 + notificationType: 'MENTION', 362 + postId: postToProcess.id 363 + } 364 + }) 365 + await PostMentionsUserRelation.destroy({ 366 + where: { 367 + postId: postToProcess.id 368 + } 369 + }) 370 + await bulkCreateNotifications( 371 + mentions.map((mnt) => ({ 372 + notificationType: 'MENTION', 373 + postId: postToProcess.id, 374 + notifiedUserId: mnt, 375 + userId: postToProcess.userId, 376 + createdAt: new Date(postToProcess.createdAt) 377 + })), 378 + { 379 + ignoreDuplicates: true, 380 + postContent: postText, 381 + userUrl: postCreator.url 382 + } 383 + ) 384 + await PostMentionsUserRelation.bulkCreate( 385 + mentions.map((mnt) => { 386 + return { 387 + userId: mnt, 388 + postId: postToProcess.id 389 + } 390 + }), 391 + { ignoreDuplicates: true } 392 + ) 393 + } 394 + if (tags.length > 0) { 395 + await PostTag.destroy({ 396 + where: { 397 + postId: postToProcess.id 398 + } 399 + }) 400 + await PostTag.bulkCreate( 401 + tags.map((tag) => { 402 + return { 403 + postId: postToProcess.id, 404 + tagName: tag 405 + } 406 + }) 407 + ) 408 + } 409 + const quotedPostUri = getQuotedPostUri(post) 410 + if (quotedPostUri) { 411 + const quotedPostId = await getAtProtoThread(quotedPostUri) 412 + if (quotedPostId) { 413 + const quotedPost = await Post.findByPk(quotedPostId) 414 + if (quotedPost) { 415 + await createNotification( 416 + { 417 + notificationType: 'QUOTE', 418 + notifiedUserId: quotedPost.userId, 419 + userId: postToProcess.userId, 420 + postId: postToProcess.id 421 + }, 422 + { 423 + postContent: postToProcess.content, 424 + userUrl: postCreator?.url 425 + } 426 + ) 427 + await Quotes.findOrCreate({ 428 + where: { 429 + quoterPostId: postToProcess.id, 430 + quotedPostId: quotedPostId 431 + } 432 + }) 433 + } 434 + } 435 + } 436 + } 437 + 438 + return postToProcess.id 439 + } 440 + } 441 + 442 + function getPostMedias(post: PostView) { 443 + let res: MediaAttributes[] = [] 444 + const labels = getPostLabels(post) 445 + const embed = (post.record as any).embed 446 + if (embed) { 447 + if (embed.external) { 448 + res = res.concat([ 449 + { 450 + mediaType: !embed.external.uri.startsWith('https://media.ternor.com/') ? 'text/html' : 'image/gif', 451 + description: embed.external.title, 452 + url: embed.external.uri, 453 + mediaOrder: 0, 454 + external: true 455 + } 456 + ]) 457 + } 458 + if (embed.images || embed.media) { 459 + // case with quote and gif / link preview 460 + if (embed.media?.external) { 461 + res = res.concat([ 462 + { 463 + mediaType: !embed.media.external.uri.startsWith('https://media.ternor.com/') ? 'text/html' : 'image/gif', 464 + description: embed.media.external.title, 465 + url: embed.media.external.uri, 466 + mediaOrder: 0, 467 + external: true 468 + } 469 + ]) 470 + } else { 471 + const thingToProcess = embed.images ? embed.images : embed.media.images 472 + if (thingToProcess) { 473 + const toConcat = thingToProcess.map((media: any, index: any) => { 474 + const cid = media.image.ref['$link'] ? media.image.ref['$link'] : media.image.ref.toString() 475 + const did = post.author.did 476 + return { 477 + mediaType: media.image.mimeType, 478 + description: media.alt, 479 + height: media.aspectRatio?.height, 480 + width: media.aspectRatio?.width, 481 + url: `?cid=${encodeURIComponent(cid)}&did=${encodeURIComponent(did)}`, 482 + mediaOrder: index, 483 + external: true 484 + } 485 + }) 486 + res = res.concat(toConcat) 487 + } else { 488 + logger.debug({ 489 + message: `Bsky problem getting medias on post ${post.uri}` 490 + }) 491 + } 492 + } 493 + } 494 + if (embed.video) { 495 + const video = embed.video 496 + const cid = video.ref['$link'] ? video.ref['$link'] : video.ref.toString() 497 + const did = post.author.did 498 + res = res.concat([ 499 + { 500 + mediaType: embed.video.mimeType, 501 + description: '', 502 + height: embed.aspectRatio?.height, 503 + width: embed.aspectRatio?.width, 504 + url: `?cid=${encodeURIComponent(cid)}&did=${encodeURIComponent(did)}`, 505 + mediaOrder: 0, 506 + external: true 507 + } 508 + ]) 509 + } 510 + } 511 + return res.map((m) => { 512 + return { 513 + ...m, 514 + NSFW: labels.length > 0 515 + } 516 + }) 517 + } 518 + 519 + // TODO improve this so we get better nsfw messages lol 520 + function getPostLabels(post: PostView) { 521 + let labels = new Set<string>() 522 + if (post.labels) { 523 + for (const label of post.labels) { 524 + if (label.neg && labels.has(label.val)) { 525 + labels.delete(label.val) 526 + } else { 527 + labels.add(label.val) 528 + } 529 + } 530 + } 531 + return Array.from(labels) 532 + } 533 + 534 + function getPostInteractionLevels( 535 + post: PostView, 536 + parentId: string | undefined 537 + ): { 538 + replyControl: InteractionControlType 539 + likeControl: InteractionControlType 540 + reblogControl: InteractionControlType 541 + quoteControl: InteractionControlType 542 + } { 543 + let canQuote = InteractionControl.Anyone 544 + let canReply: InteractionControlType = InteractionControl.Anyone 545 + if (post.viewer && post.viewer.embeddingDisabled) { 546 + canQuote = InteractionControl.NoOne 547 + } 548 + if (parentId) { 549 + canReply = InteractionControl.SameAsOp 550 + canQuote = InteractionControl.SameAsOp 551 + } else if (post.threadgate && post.threadgate.record && (post.threadgate.record as any).allow) { 552 + const allowList = (post.threadgate.record as any).allow 553 + if (allowList.length == 0) { 554 + canReply = InteractionControl.NoOne 555 + } else { 556 + const mentiontypes: string[] = allowList 557 + .map((elem: any) => elem['$type']) 558 + .map((elem: string) => elem.split('app.bsky.feed.threadgate#')[1]) 559 + if (mentiontypes.includes('mentionRule')) { 560 + if (mentiontypes.includes('followingRule')) { 561 + canReply = mentiontypes.includes('followerRule') 562 + ? InteractionControl.FollowersFollowersAndMentioned 563 + : InteractionControl.FollowingAndMentioned 564 + } else { 565 + canReply = mentiontypes.includes('followerRule') 566 + ? InteractionControl.FollowersAndMentioned 567 + : InteractionControl.MentionedUsersOnly 568 + } 569 + } else { 570 + if (mentiontypes.includes('followingRule')) { 571 + canReply = mentiontypes.includes('followerRule') 572 + ? InteractionControl.FollowersAndFollowing 573 + : InteractionControl.Following 574 + } else { 575 + canReply = mentiontypes.includes('followerRule') ? InteractionControl.Followers : InteractionControl.NoOne 576 + } 577 + } 578 + } 579 + } 580 + 581 + if (canQuote === InteractionControl.Anyone && canReply != InteractionControl.Anyone) { 582 + canQuote = canReply 583 + } 584 + 585 + return { 586 + quoteControl: canQuote, 587 + replyControl: canReply, 588 + likeControl: InteractionControl.Anyone, 589 + reblogControl: InteractionControl.Anyone 590 + } 591 + } 592 + 593 + export { processSinglePostJob }
packages/backend/cache/.gitkeep

This is a binary file and will not be displayed.

+2 -3
packages/backend/contexts/litepub-0.1.jsonld
··· 5 5 { 6 6 "Emoji": "toot:Emoji", 7 7 "Hashtag": "as:Hashtag", 8 - "WafrnHashtag": "as:WafrnHashtag", 8 + "WafrnHashtag":"as:WafrnHashtag", 9 9 "PropertyValue": "schema:PropertyValue", 10 10 "atomUri": "ostatus:atomUri", 11 11 "conversation": { ··· 23 23 "value": "schema:value", 24 24 "sensitive": "as:sensitive", 25 25 "litepub": "http://litepub.social/ns#", 26 - "wafrn": "https://wafrn.net/ns#", 27 26 "invisible": "litepub:invisible", 28 27 "directMessage": "litepub:directMessage", 29 28 "listMessage": { ··· 50 49 } 51 50 } 52 51 ] 53 - } 52 + }
+1 -9
packages/backend/environment.dev.ts
··· 123 123 externalCacheurl: '/api/cache?media=', 124 124 shortenPosts: 3, 125 125 disablePWA: false, 126 - maintenance: false, 127 - oidcAuthName: 'OpenID', 128 - oidcEnabled: true, 129 - }, 130 - oidcConfig: { 131 - enabled: true, 132 - issuer: 'http://auth.localhost/', 133 - clientId: 'wafrn', 134 - clientSecret: "this is a secret" 126 + maintenance: false 135 127 } 136 128 }
+1 -9
packages/backend/environment.example.ts
··· 121 121 shortenPosts: ${{FRONTEND_SHORTEN_POSTS:-3}}, 122 122 disablePWA: ${{FRONTEND_DISABLE_PWA:-false}}, 123 123 maintenance: ${{FRONTEND_MAINTENANCE:-false}}, 124 - enableRawOutput: ${{ENABLE_RAW_OUTPUT:-false}}, 125 - oidcAuthName: '${{OIDC_AUTH_NAME:-OpenID}}', 126 - oidcEnabled: ${{OIDC_ENABLED:-false}} 127 - }, 128 - oidcConfig: { 129 - enabled: ${{OIDC_ENABLED:-false}}, 130 - issuer: '${{OIDC_ISSUER:-http://auth.localhost/}}', 131 - clientId: '${{OIDC_CLIENT_ID:-wafrn}}', 132 - clientSecret: '${{OIDC_CLIENT_SECRET:-secret}}' 124 + enableRawOutput: ${{ENABLE_RAW_OUTPUT:-false}} 133 125 } 134 126 }
-6
packages/backend/environment.local.example.ts
··· 119 119 disablePWA: false, 120 120 maintenance: false, 121 121 enableRawOutput: true 122 - }, 123 - oidcConfig: { 124 - enabled: true, 125 - issuer: 'http://auth.localhost/', 126 - clientId: 'wafrn', 127 - clientSecret: "this is a secret" 128 122 } 129 123 }
+1 -7
packages/backend/interfaces/environment.ts
··· 78 78 webpushPrivateKey: string 79 79 webpushPublicKey: string 80 80 webpushEmail: string 81 - frontendEnvironment: any, 82 - oidcConfig: { 83 - enabled: boolean, 84 - issuer: string, 85 - clientId: string, 86 - clientSecret: string 87 - } 81 + frontendEnvironment: any 88 82 }
+1 -2
packages/backend/package.json
··· 1 1 { 2 2 "name": "wafrn-backend", 3 - "version": "2025.10.03-DEV+JBC", 3 + "version": "2025.10.03-DEV", 4 4 "description": "wafrn backend", 5 5 "main": "index.js", 6 6 "type": "module", ··· 87 87 "node-cron": "^4.2.1", 88 88 "node-stream-zip": "^1.15.0", 89 89 "nodemailer": "^6.7.2", 90 - "openid-client": "^6.8.1", 91 90 "otpauth": "^9.4.0", 92 91 "pg": "^8.11.0", 93 92 "pg-hstore": "^2.3.4",
+1 -1
packages/backend/routes/activitypub/activitypub.ts
··· 56 56 getCheckFediverseSignatureFunction(false), 57 57 async (req: SignedRequest, res: Response) => { 58 58 const url = req.params.url.toLowerCase() 59 - if (req.headers['accept']?.includes('*') && !req.query.jsonld) { 59 + if (req.headers['accept']?.includes('*')) { 60 60 res.redirect(`/blog/${url}`) 61 61 return 62 62 }
+3 -29
packages/backend/routes/activitypub/well-known.ts
··· 4 4 import { User, Post } from '../../models/index.js' 5 5 import { getAllLocalUserIds } from '../../utils/cacheGetters/getAllLocalUserIds.js' 6 6 import { return404 } from '../../utils/return404.js' 7 - import { getAdminUser } from '../../utils/getAdminAndDeletedUser.js' 8 7 import fs from 'fs' 9 8 10 9 // @ts-ignore cacher has no types ··· 15 14 const cacher = new Cacher() 16 15 17 16 function wellKnownRoutes(app: Application) { 18 - app.get('/.well-known/security.txt', (req, res) => { 19 - res.send( 20 - `Contact: mailto:jb@jbc.lol 21 - Expires: 2030-12-24T21:50:00Z` 22 - ) 23 - res.end() 24 - }) 25 - 26 17 // webfinger protocol 27 18 app.get('/.well-known/host-meta', (req: Request, res) => { 28 19 res.send( ··· 103 94 activated: true 104 95 } 105 96 }) 106 - const adminUser = await getAdminUser(); 107 97 const activeUsersSixMonths = await User.count({ 108 98 where: { 109 99 id: { ··· 147 137 version: '2.0', 148 138 software: { 149 139 name: 'wafrn', 150 - version: packageJsonFile.version, 151 - homepage: "https://codeberg.org/wafrn/wafrn", 152 - repository: "https://codeberg.org/wafrn/wafrn" 140 + version: packageJsonFile.version 153 141 }, 154 142 protocols: ['activitypub'], 155 143 services: { 156 - outbound: [ 157 - "atom1.0" 158 - ], 144 + outbound: [], 159 145 inbound: [] 160 146 }, 161 147 usage: { ··· 173 159 } 174 160 }) 175 161 }, 176 - openRegistrations: false, 162 + openRegistrations: true, 177 163 metadata: { 178 - nodeName: completeEnvironment.defaultSEOData.title, 179 - nodeDescription: completeEnvironment.defaultSEOData.description, 180 - nodeAdmins: [ 181 - { 182 - name: adminUser.url, 183 - email: adminUser.url + '@' + completeEnvironment.instanceUrl 184 - } 185 - ], 186 - maintainer: { 187 - name: adminUser.url, 188 - email: adminUser.url + '@' + completeEnvironment.instanceUrl 189 - }, 190 164 themeColor: '#96d8d1' 191 165 } 192 166 }
+2 -2
packages/backend/routes/bite.ts
··· 15 15 export default function biteRoutes(app: Application) { 16 16 app.post( 17 17 '/api/bitePost', 18 - // biteLimiter, // no 18 + biteLimiter, 19 19 authenticateToken, 20 20 forceUpdateLastActive, 21 21 async (req: AuthorizedRequest, res: Response) => { ··· 79 79 80 80 app.post( 81 81 '/api/bite', 82 - // biteLimiter, no. 82 + biteLimiter, 83 83 authenticateToken, 84 84 forceUpdateLastActive, 85 85 async (req: AuthorizedRequest, res: Response) => {
+86 -23
packages/backend/routes/dashboard.ts
··· 46 46 let whereObject: any = { 47 47 privacy: Privacy.Public 48 48 } 49 + 50 + const dbOptiondisableReplies = await UserOptions.findOne({ 51 + where: { 52 + userId: posterId, 53 + optionName: 'wafrn.disableReplies' 54 + } 55 + }) 56 + 57 + const disableReplies = dbOptiondisableReplies?.optionValue === 'true' 58 + const disableRepliesOr = [ 59 + { 60 + isReblog: true 61 + }, 62 + { 63 + parentId: null 64 + } 65 + ] 66 + 49 67 switch (level) { 50 68 case 2: { 51 69 let hideReblogs = false ··· 61 79 } 62 80 const followedUsers = getFollowedsIds(posterId, true) 63 81 const nonFollowedUsers = getNonFollowedLocalUsersIds(posterId) 64 - whereObject = { 65 - [Op.or]: [ 66 - { 67 - privacy: { 68 - [Op.in]: [Privacy.Public, Privacy.FollowersOnly, Privacy.LocalOnly] 82 + 83 + const and: any = [ 84 + { 85 + [Op.or]: [ 86 + { 87 + privacy: { 88 + [Op.in]: [Privacy.Public, Privacy.FollowersOnly, Privacy.LocalOnly] 89 + }, 90 + userId: { 91 + [Op.in]: await followedUsers 92 + } 69 93 }, 70 - userId: { 71 - [Op.in]: await followedUsers 72 - } 73 - }, 74 - { 75 - privacy: { 76 - [Op.in]: req.jwtData?.userId ? [Privacy.Public, Privacy.LocalOnly] : [Privacy.Public] // only display public if not logged in 94 + { 95 + privacy: { 96 + [Op.in]: req.jwtData?.userId ? [Privacy.Public, Privacy.LocalOnly] : [Privacy.Public] // only display public if not logged in 97 + }, 98 + userId: { 99 + [Op.in]: await nonFollowedUsers 100 + } 77 101 }, 78 - userId: { 79 - [Op.in]: await nonFollowedUsers 102 + { 103 + userId: posterId, 104 + privacy: { 105 + [Op.ne]: Privacy.DirectMessage 106 + } 80 107 } 81 - }, 82 - { 83 - userId: posterId, 84 - privacy: { 85 - [Op.ne]: Privacy.DirectMessage 86 - } 87 - } 88 - ], 108 + ] 109 + } 110 + ] 111 + 112 + if (disableReplies) { 113 + and.push({ 114 + [Op.or]: disableRepliesOr 115 + }) 116 + } 117 + 118 + whereObject = { 119 + [Op.and]: and, 89 120 isReblog: { 90 121 [Op.in]: hideReblogs ? [false, null] : [true, false, null] 91 122 } 92 123 } 124 + 93 125 break 94 126 } 95 127 case 1: { ··· 98 130 userId: { [Op.in]: await getFollowedsIds(posterId) } 99 131 } 100 132 ] 133 + 134 + const dbOptionDisableRewootsDashboard = await UserOptions.findOne({ 135 + where: { 136 + userId: posterId, 137 + optionName: 'wafrn.disableRewootsDashboard' 138 + } 139 + }) 140 + 141 + const hideReblogs = dbOptionDisableRewootsDashboard?.optionValue === 'true' 101 142 const subscribedTags = await getFollowedHashtags(posterId) 143 + 102 144 if (subscribedTags && subscribedTags.length > 0) { 103 145 // query: get posts with hashtag thing 104 146 postsWithTags = PostTag.findAll({ ··· 131 173 order: [['createdAt', 'DESC']] 132 174 }) 133 175 } 176 + 177 + const and: any = [ 178 + { 179 + [Op.or]: orConditions 180 + } 181 + ] 182 + 183 + if (disableReplies) { 184 + and.push({ 185 + [Op.or]: disableRepliesOr 186 + }) 187 + } 188 + 134 189 whereObject = { 135 190 privacy: { [Op.in]: [Privacy.Public, Privacy.FollowersOnly, Privacy.LocalOnly, Privacy.Unlisted] }, 136 - [Op.or]: orConditions 191 + isReblog: { 192 + [Op.in]: hideReblogs ? [false, null] : [true, false, null] 193 + }, 194 + [Op.and]: and 137 195 } 196 + 138 197 break 139 198 } 140 199 case 0: { ··· 153 212 } 154 213 ] 155 214 } 215 + 216 + if (disableReplies) 217 + whereObject.parentId = null 218 + 156 219 break 157 220 } 158 221 case 10: {
+87 -167
packages/backend/routes/users.ts
··· 23 23 import sendEmail from '../utils/sendEmail.js' 24 24 import validateEmail from '../utils/validateEmail.js' 25 25 import bcrypt from 'bcrypt' 26 - import jwt, { JwtPayload } from 'jsonwebtoken' 26 + import jwt from 'jsonwebtoken' 27 27 import { sequelize } from '../models/index.js' 28 28 29 29 import optimizeMedia from '../utils/optimizeMedia.js' ··· 66 66 import { getAllLocalUserIds } from '../utils/cacheGetters/getAllLocalUserIds.js' 67 67 import { syncBskyFollowersAndFollowing } from '../utils/atproto/syncBskyFollowersAndFollowing.js' 68 68 import { getAdminUser } from '../utils/getAdminAndDeletedUser.js' 69 - import * as openId from 'openid-client' 70 69 71 70 const markdownConverter = new showdown.Converter({ 72 71 simplifiedAutoLink: true, ··· 96 95 ? completeEnvironment.bskyPds 97 96 : 'https://' + completeEnvironment.bskyPds 98 97 : '' 99 - 100 - const openIdConfig: openId.Configuration | undefined = (completeEnvironment.oidcConfig.enabled) ? await openId.discovery( 101 - new URL(completeEnvironment.oidcConfig.issuer), 102 - completeEnvironment.oidcConfig.clientId, 103 - completeEnvironment.oidcConfig.clientSecret, 104 - ) : undefined 105 98 106 99 const deletePostQueue = new Queue('deletePostQueue', { 107 100 connection: completeEnvironment.bullmqConnection, ··· 116 109 } 117 110 }) 118 111 112 + 113 + const slurs = [ 114 + "chinaman", 115 + "chinamen", 116 + "chink", 117 + "coolie", 118 + "coon", 119 + "eskimo", 120 + "golliwog", 121 + "gook", 122 + "gyp", 123 + "gypsy", 124 + "half-breed", 125 + "halfbreed", 126 + "heeb", 127 + "jap", 128 + "kaffer", 129 + "kaffir", 130 + "kaffir", 131 + "kaffre", 132 + "kafir", 133 + "kike", 134 + "kraut", 135 + "negress", 136 + "negro", 137 + "nig", 138 + "nig-nog", 139 + "nigga", 140 + "nigger", 141 + "nigguh", 142 + "pajeet", 143 + "paki", 144 + "pickaninnie", 145 + "pickaninny", 146 + "raghead", 147 + "retard", 148 + "sambo", 149 + "shemale", 150 + "soyboy", 151 + "spade", 152 + "sperg", 153 + "spic", 154 + "squaw", 155 + "tard", 156 + "wetback", 157 + "wigger", 158 + "wop", 159 + "yid", 160 + ] 161 + 119 162 function userRoutes(app: Application) { 120 163 app.post( 121 164 '/api/register', ··· 129 172 req.body?.email && 130 173 req.body.url && 131 174 req.body.url.match(/^[a-z0-9_A-Z]+([\_-]+[a-z0-9_A-Z]+)*$/i) && 132 - validateEmail(req.body.email) 175 + validateEmail(req.body.email) && 176 + !slurs.includes(req.body.url.toLowerCase() && 177 + slurs.every(elem => !req.body.url.includes(elem))) 133 178 ) { 134 179 const birthDate = new Date(req.body.birthDate) 135 180 const minimumAge = new Date() ··· 195 240 const emailSent = completeEnvironment.disableRequireSendEmail 196 241 ? true 197 242 : sendEmail({ 198 - email, 199 - subject: `Welcome to ${instanceHost}, please verify your email!`, 200 - body: `\ 243 + email, 244 + subject: `Welcome to ${instanceHost}, please verify your email!`, 245 + body: `\ 201 246 <h1>Welcome to ${instanceUrl}</h1> 202 247 <p>To activate your account, <a href="${activationLink}">verify your email</a>.</p> 203 248 <br /> 204 249 <p>If you can't see the link above, copy this link: ${activationLink}</p> 205 250 ` 206 - }) 251 + }) 207 252 await Promise.all([userWithEmail, emailSent]) 208 253 await generateUserKeyPairQueue.add('generateUserKeyPair', { userId: (await userWithEmail).id }) 209 254 success = true ··· 594 639 } 595 640 }) 596 641 597 - app.get('/api/login/oidc', loginRateLimiter, async (req, res) => { 598 - if (!completeEnvironment.oidcConfig.enabled || !openIdConfig) { 599 - res.redirect('/login?error=openid_disabled') 600 - return 601 - } 602 - 603 - let redirectUri = new URL('/api/login/oidc/callback', completeEnvironment.frontendUrl).href 604 - let scope = 'openid email profile' 605 - 606 - const stateNonce = openId.randomNonce() 607 - const stateToken = openId.randomState() 608 - let state = jwt.sign( 609 - { 610 - openIdStep: 1, 611 - stateToken, 612 - stateNonce, 613 - }, 614 - completeEnvironment.jwtSecret, 615 - { expiresIn: '10m' } 616 - ) 617 - 618 - const decodedState = jwt.verify(`${state}`, completeEnvironment.jwtSecret, {}); 619 - console.log(state, decodedState); 620 - 621 - let parameters: Record<string, string> = { 622 - redirect_uri: redirectUri, 623 - state, 624 - scope, 625 - nonce: stateNonce 626 - } 627 - 628 - let redirectTo: URL = openId.buildAuthorizationUrl(openIdConfig, parameters) 629 - 630 - res.redirect(301, redirectTo.href); 631 - }) 632 - 633 - app.get('/api/login/oidc/callback', loginRateLimiter, async (req, res) => { 634 - if (!completeEnvironment.oidcConfig.enabled || !openIdConfig) { 635 - res.redirect('/login?error=openid_disabled') 636 - return 637 - } 638 - 639 - if (!req.query.code || !req.query.state) { 640 - return res.redirect('/login?error=missing_parameters'); 641 - } 642 - 643 - try { 644 - const stateToken = req.query.state as string; 645 - console.log(req.query); 646 - const decodedState = jwt.verify(`${stateToken}`, completeEnvironment.jwtSecret, { clockTolerance: 5 }); 647 - 648 - const url = new URL(req.url, `https://${req.headers.host ?? 'localhost'}`); 649 - 650 - const tokenSet: openId.TokenEndpointResponse = await openId.authorizationCodeGrant( 651 - openIdConfig, 652 - url, 653 - { 654 - expectedNonce: (decodedState as JwtPayload).stateNonce, 655 - expectedState: stateToken, 656 - } 657 - ) 658 - 659 - const userInfo = await openId.fetchUserInfo( 660 - openIdConfig, 661 - tokenSet.access_token, 662 - openId.skipSubjectCheck 663 - ) 664 - 665 - if (!userInfo.email) { 666 - res.redirect('/login?error=no_email_configured') 667 - return 668 - } 669 - 670 - const userWithEmail = await User.scope('full').findOne({ 671 - where: { 672 - email: userInfo.email.toLowerCase().trim(), 673 - banned: { 674 - [Op.ne]: true 675 - } 676 - } 677 - }) 678 - 679 - if (!userWithEmail) { 680 - res.redirect(`/register?email=${userInfo.email}&username=${userInfo.preferred_username}`) 681 - return 682 - } 683 - 684 - if (userWithEmail.activated) { 685 - const mfaEnabled = await MfaDetails.findAll({ 686 - where: { 687 - userId: userWithEmail.id, 688 - enabled: { 689 - [Op.eq]: true 690 - } 691 - } 692 - }) 693 - if (mfaEnabled.length > 0) { 694 - const token = jwt.sign( 695 - { 696 - mfaStep: 1, 697 - email: userWithEmail.email?.toLowerCase() 698 - }, 699 - completeEnvironment.jwtSecret, 700 - { expiresIn: '300s' } 701 - ) 702 - res.redirect(`/login?openid_token=${token}&mfa_required=true`) 703 - } else { 704 - const token = jwt.sign( 705 - { 706 - userId: userWithEmail.id, 707 - email: userWithEmail.email?.toLowerCase(), 708 - birthDate: userWithEmail.birthDate, 709 - url: userWithEmail.url, 710 - role: userWithEmail.role 711 - }, 712 - completeEnvironment.jwtSecret, 713 - { expiresIn: '31536000s' } 714 - ) 715 - res.redirect(`/login?openid_token=${token}`) 716 - userWithEmail.lastLoginIp = getIp(req, true) 717 - await userWithEmail.save() 718 - } 719 - } else { 720 - res.redirect('/login?error=user_not_activated') 721 - } 722 - } catch (e) { 723 - console.error(e); 724 - res.redirect('/login?error=openid_failed_auth') 725 - } 726 - }) 727 - 728 642 app.post('/api/login/mfa', [loginRateLimiter, optionalAuthentication], async (req: AuthorizedRequest, res: any) => { 729 643 let success = false 730 644 try { ··· 1004 918 let followed = blog.isRemoteUser 1005 919 ? blog.followingCount 1006 920 : Follows.count({ 1007 - where: { 1008 - followerId: blog.id, 1009 - accepted: true 1010 - } 1011 - }) 921 + where: { 922 + followerId: blog.id, 923 + accepted: true 924 + } 925 + }) 1012 926 let followers = blog.isRemoteUser 1013 927 ? blog.followerCount 1014 928 : Follows.count({ 1015 - where: { 1016 - followedId: blog.id, 1017 - accepted: true 1018 - } 1019 - }) 929 + where: { 930 + followedId: blog.id, 931 + accepted: true 932 + } 933 + }) 1020 934 const publicOptions = UserOptions.findAll({ 1021 935 where: { 1022 936 userId: blog.id, ··· 1055 969 1056 970 const postCount = blog 1057 971 ? await Post.count({ 1058 - where: { 1059 - userId: blog.id 1060 - } 1061 - }) 972 + where: { 973 + userId: blog.id 974 + } 975 + }) 1062 976 : 0 1063 977 1064 978 followed = await followed ··· 1557 1471 if (req.body.anonymous) { 1558 1472 req.jwtData = undefined 1559 1473 } 1474 + if(req.body.question) { 1475 + if(slurs.includes(req.body.question.toLowerCase()) || req.body.question.toLowerCase().includes('kill yourself')) { 1476 + res.status(400) 1477 + return res.send({error: true, message: 'Your ask seems to be harmful. Fuck you.'}) 1478 + } 1479 + } 1560 1480 const lastHourAsks = await Ask.count({ 1561 1481 where: { 1562 1482 creationIp: getIp(req), ··· 1814 1734 message = `Alias not detected` 1815 1735 } 1816 1736 } 1817 - } catch (error) { } 1737 + } catch (error) {} 1818 1738 } 1819 1739 1820 1740 res.status(success ? 200 : 500) ··· 1902 1822 }) 1903 1823 userOption 1904 1824 ? await userOption.update({ 1905 - optionValue: option.value, 1906 - public: option.public == true 1907 - }) 1825 + optionValue: option.value, 1826 + public: option.public == true 1827 + }) 1908 1828 : await UserOptions.create({ 1909 - userId: posterId, 1910 - optionName: option.name, 1911 - optionValue: option.value, 1912 - public: option.public == true 1913 - }) 1829 + userId: posterId, 1830 + optionName: option.name, 1831 + optionValue: option.value, 1832 + public: option.public == true 1833 + }) 1914 1834 } 1915 1835 } 1916 1836 }
+7 -12
packages/backend/routes/websocket.ts
··· 49 49 const token = msgAsObject.object 50 50 jwt.verify(token, completeEnvironment.jwtSecret, async (err: any, jwtData: any) => { 51 51 if (err) { 52 - ws.close(); 53 - console.error('websocket error', err); 52 + ws.close() 54 53 } 55 54 if (!jwtData?.userId) { 56 - console.error('websocket error', jwtData) 57 55 ws.close() 58 56 } else { 59 57 authorized = true ··· 64 62 } 65 63 }) 66 64 } else { 67 - console.error('websocket error', msgAsObject); 68 65 ws.close() 69 66 } 70 67 break ··· 79 76 } 80 77 }) 81 78 82 - ws.on('close', (msg: string) => { 83 - logger.info('ws closed', msg); 84 - }) 79 + ws.on('close', (msg: string) => {}) 85 80 86 81 // if it has been one second and user has not started the auth process, time to kill this process 87 82 // if user failed auth and for any destiny's reason we are still on it 88 83 // in case of failure the auth part would take care of killing the connection 89 - // setTimeout(() => { 90 - // if (!authorized && !procesingAuth) { 91 - // ws.close() 92 - // } 93 - // }, 1000) 84 + setTimeout(() => { 85 + if (!authorized && !procesingAuth) { 86 + ws.close() 87 + } 88 + }, 1000) 94 89 }) 95 90 }
packages/backend/uploads/.gitkeep

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/.gitkeep

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_cool.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_evil.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_evil_3c.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_heart.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_party.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_pc.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_pride.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_sad.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_shocked.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_sip.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_thumbsup.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/waffy_woozy.png

This is a binary file and will not be displayed.

packages/backend/uploads/emojipacks/waffy/yeah_waffy.png

This is a binary file and will not be displayed.

packages/backend/uploads/themes/.gitkeep

This is a binary file and will not be displayed.

+1 -9
packages/backend/utils/activitypub/contexts.ts
··· 554 554 value: 'schema:value', 555 555 sensitive: 'as:sensitive', 556 556 litepub: 'http://litepub.social/ns#', 557 - wafrn: 'https://wafrn.net/ns#', 558 557 invisible: 'litepub:invisible', 559 558 directMessage: 'litepub:directMessage', 560 559 listMessage: { ··· 631 630 listenbrainz: 'sharkey:listenbrainz', 632 631 enableRss: 'sharkey:enableRss', 633 632 // vcard 634 - vcard: 'http://www.w3.org/2006/vcard/ns#', 635 - // wafrn 636 - wafrn: 'https://wafrn.net/ns#', 637 - bskyDid: 'wafrn:bskyDid', 638 - allowAsks: 'wafrn:allowAsks', 639 - allowAnonymousAsks: 'wafrn:allowAnonymousAsks', 640 - bskyUri: 'wafrn:bskyUri', 641 - bskyCid: 'wafrn:bskyCid' 633 + vcard: 'http://www.w3.org/2006/vcard/ns#' 642 634 } satisfies Context 643 635 644 636 export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition]
+3 -9
packages/backend/utils/activitypub/getPostThreadRecursive.ts
··· 167 167 const tags = dom('a.hashtag').html('') 168 168 postTextContent = dom.html() 169 169 } 170 - 171 170 if ( 172 171 postPetition.attachment && 173 172 postPetition.attachment.length > 0 && ··· 210 209 content_warning: postPetition.summary 211 210 ? postPetition.summary 212 211 : remoteUser.NSFW 213 - ? 'User is marked as NSFW by this instance staff. Possible NSFW without tagging' 214 - : '', 212 + ? 'User is marked as NSFW by this instance staff. Possible NSFW without tagging' 213 + : '', 215 214 createdAt: createdAt, 216 215 updatedAt: createdAt, 217 216 userId: remoteUserServerBaned || remoteUser.banned ? (await deletedUser)?.id : remoteUser.id, 218 217 remotePostId: postPetition.id, 219 - privacy: privacy, 220 - 221 - // if this is there, we're probably talking to another Wafrn instance 222 - // or something that sends this (but mostly Wafrn... well until Bridgy works with this) 223 - bskyUri: postPetition.bskyUri, 224 - bskyCid: postPetition.bskyCid 218 + privacy: privacy 225 219 } 226 220 227 221 if (postPetition.name) {
+6 -4
packages/backend/utils/activitypub/postToJSONLD.ts
··· 207 207 published: new Date(post.createdAt).toISOString(), 208 208 updated: new Date(post.updatedAt).toISOString(), 209 209 url: post.bskyUri 210 - ? [`${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, post.bskyUri] 210 + ? [`${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, { 211 + type: "Link", 212 + rel: "alternate", 213 + href: post.bskyUri 214 + }] 211 215 : `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 212 216 attributedTo: `${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}`, 213 217 to: usersToSend.to, ··· 219 223 quoteUrl: misskeyQuoteURL, 220 224 _misskey_quote: misskeyQuoteURL, 221 225 quoteUri: misskeyQuoteURL, 222 - bskyUri: post.bskyUri, 223 - bskyCid: post.bskyCid, 224 226 // conversation: conversationString, 225 227 content: (processedContent + tagsAndQuotes).replace(lineBreaksAtEndRegex, ''), 226 228 attachment: postMedias ··· 230 232 return { 231 233 type: 'Document', 232 234 mediaType: media.mediaType, 233 - url: (media.url.startsWith('?cid') || media.external) ? 235 + url: (media.url.startsWith('?cid') || media.external) ? 234 236 completeEnvironment.externalCacheurl + encodeURIComponent(media.url) : 235 237 (completeEnvironment.mediaUrl + media.url), 236 238 sensitive: media.NSFW ? true : false,
+2 -4
packages/backend/utils/activitypub/userToJSONLD.ts
··· 19 19 let alsoKnownAsList = userOptions.find((elem) => elem.optionName === 'fediverse.public.alsoKnownAs') 20 20 if (alsoKnownAsList?.optionValue) { 21 21 try { 22 - const parsedValue = JSON.parse(alsoKnownAsList?.optionValue) 22 + const parsedValue = alsoKnownAsList?.optionValue 23 23 if (typeof parsedValue === 'string') { 24 24 for (let elem of parsedValue.split(',')) { 25 25 let url = new URL(elem) ··· 62 62 manuallyApprovesFollowers: user.manuallyAcceptsFollows, 63 63 discoverable: true, 64 64 alsoKnownAs: alsoKnownAs, 65 - bskyDid: user.bskyDid, 66 65 published: user.createdAt, 67 66 tag: emojis.map((emoji: any) => emojiToAPTag(emoji)), 68 67 endpoints: { ··· 90 89 id: `${completeEnvironment.frontendUrl}/fediverse/blog/${user.url.toLowerCase()}#main-key`, 91 90 owner: `${completeEnvironment.frontendUrl}/fediverse/blog/${user.url.toLowerCase()}`, 92 91 publicKeyPem: user.publicKey 93 - }, 94 - 92 + } 95 93 } 96 94 97 95 if (user.userMigratedTo) {
-1
packages/backend/utils/queueProcessors/getRemoteActorIdProcessor.ts
··· 96 96 remoteId: actorUrl, 97 97 activated: true, 98 98 federatedHostId: federatedHost.id, 99 - bskyDid: userPetition.bskyDid, 100 99 remoteMentionUrl: remoteMentionUrl, 101 100 followersCollectionUrl: userPetition.followers, 102 101 followingCollectionUrl: userPetition.following,
+24 -8
packages/backend/utils/workers.ts
··· 12 12 import { generateUserKeyPair } from './queueProcessors/generateUserKeyPair.js' 13 13 import { completeEnvironment } from './backendOptions.js' 14 14 import { sendPostBsky } from './queueProcessors/sendPostBsky.js' 15 + import { processSinglePostJob } from '../atproto/workers/processSinglePostWorker.js' 15 16 16 17 logger.info('started worker') 17 18 const workerInbox = new Worker('inbox', (job: Job) => inboxWorker(job), { ··· 95 96 96 97 const workerProcessFirehose = completeEnvironment.enableBsky 97 98 ? new Worker('firehoseQueue', async (job: Job) => await processFirehose(job), { 98 - connection: completeEnvironment.bullmqConnection, 99 - metrics: { 100 - maxDataPoints: MetricsTime.ONE_WEEK * 2 101 - }, 102 - concurrency: completeEnvironment.workers.medium, 103 - // up to one minute 104 - lockDuration: 60000 105 - }) 99 + connection: completeEnvironment.bullmqConnection, 100 + metrics: { 101 + maxDataPoints: MetricsTime.ONE_WEEK * 2 102 + }, 103 + concurrency: completeEnvironment.workers.medium, 104 + // up to one minute 105 + lockDuration: 60000 106 + }) 107 + : null 108 + 109 + const workerProcessSinglePost = completeEnvironment.enableBsky 110 + ? new Worker('processSinglePost', async (job: Job) => await processSinglePostJob(job), { 111 + connection: completeEnvironment.bullmqConnection, 112 + metrics: { 113 + maxDataPoints: MetricsTime.ONE_WEEK * 2 114 + }, 115 + concurrency: 25, 116 + // up to one minute 117 + lockDuration: 60000 118 + }) 106 119 : null 107 120 108 121 const workerSendPushNotification = new Worker( ··· 156 169 ] 157 170 if (completeEnvironment.enableBsky) { 158 171 workers.push(workerProcessFirehose as Worker) 172 + workers.push(workerProcessSinglePost as Worker) 159 173 workers.push(workerSendPostBsky as Worker) 160 174 } 161 175 ··· 186 200 ] 187 201 if (completeEnvironment.enableBsky) { 188 202 workersToLogFail.push(workerProcessFirehose as Worker) 203 + workersToLogFail.push(workerProcessSinglePost as Worker) 189 204 workersToLogFail.push(workerSendPostBsky as Worker) 190 205 } 191 206 ··· 207 222 workerProcessRemotePostView, 208 223 workerProcessRemoteMediaData, 209 224 workerProcessFirehose, 225 + workerProcessSinglePost, 210 226 workerSendPushNotification, 211 227 workerCheckPushNotificationDelivery, 212 228 workerGenerateUserKeyPair,
-1
packages/caddy/global/disable_https.conf
··· 1 - auto_https disable_redirects
+2
packages/frontend/.gitignore
··· 10 10 # settings 11 11 Caddyfile 12 12 13 + # override directory 14 + /overrides 13 15 14 16 # dependencies 15 17 /node_modules
+11 -15
packages/frontend/Caddyfile.example
··· 11 11 12 12 admin 0.0.0.0:2019 13 13 14 + on_demand_tls { 15 + ask http://${{PDS_HOST:-pds:3000}}/tls-check 16 + } 17 + 14 18 import /etc/caddy/config/global/* ${{DOMAIN_NAME}} 15 19 } 16 20 17 - :9239 { 21 + ${{MEDIA_DOMAIN}} { 18 22 import /etc/caddy/config/media_domain_pre/* ${{DOMAIN_NAME}} ${{MEDIA_DOMAIN}} 19 23 20 24 handle { ··· 26 30 import /etc/caddy/config/media_domain_post/* ${{DOMAIN_NAME}} ${{MEDIA_DOMAIN}} 27 31 } 28 32 29 - :9238 { 33 + ${{CACHE_DOMAIN}} { 30 34 import /etc/caddy/config/cache_domain_pre/* ${{DOMAIN_NAME}} ${{CACHE_DOMAIN}} 31 35 32 36 handle /api/cache* { ··· 36 40 import /etc/caddy/config/cache_domain_post/* ${{DOMAIN_NAME}} ${{CACHE_DOMAIN}} 37 41 } 38 42 39 - :9237 { 43 + ${{DOMAIN_NAME}} { 40 44 import /etc/caddy/config/main_domain_pre/* ${{DOMAIN_NAME}} 41 45 42 46 header * { ··· 70 74 import /etc/caddy/config/main_domain_post/* ${{DOMAIN_NAME}} 71 75 } 72 76 73 - :9241 { 77 + monitoring.${{DOMAIN_NAME}} { 74 78 import /etc/caddy/config/monitoring_domain_pre/* ${{DOMAIN_NAME}} 75 79 76 80 reverse_proxy ${{GRAFANA_HOST:-grafana:2345}} ··· 78 82 import /etc/caddy/config/monitoring_domain_post/* ${{DOMAIN_NAME}} 79 83 } 80 84 81 - :9240 { 85 + ${{PDS_DOMAIN_NAME}} *.${{PDS_DOMAIN_NAME}} { 82 86 import /etc/caddy/config/pds_domain_pre/* ${{DOMAIN_NAME}} ${{PDS_DOMAIN_NAME}} 83 87 84 - handle / { 85 - root * /pds-homepage 86 - try_files {path} /pds.txt 87 - file_server 88 - } 89 - 90 - handle / { 91 - root * /pds-homepage 92 - try_files {path} /pds.txt 93 - file_server 88 + tls { 89 + on_demand 94 90 } 95 91 96 92 handle / {
packages/frontend/overrides/assets/icons/icon-128x128.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-144x144.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-152x152.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-192x192.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-384x384.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-512x512.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-72x72.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/icons/icon-96x96.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/linkpreview.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/logo.avif

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/logo.png

This is a binary file and will not be displayed.

packages/frontend/overrides/assets/logo_w.png

This is a binary file and will not be displayed.

packages/frontend/overrides/favicon.ico

This is a binary file and will not be displayed.

+1 -1
packages/frontend/package.json
··· 1 1 { 2 2 "name": "wafrn", 3 - "version": "2025.10.03-DEV+JBC", 3 + "version": "2025.10.03-DEV", 4 4 "scripts": { 5 5 "ng": "ng", 6 6 "start": "npm run prebuild && ng serve",
+2 -3
packages/frontend/src/app/app-routing.module.ts
··· 23 23 24 24 { 25 25 path: 'register', 26 - loadChildren: () => import('./pages/register/register.module').then((m) => m.RegisterModule), 27 - canActivate: [isAdminGuard] 26 + loadChildren: () => import('./pages/register/register.module').then((m) => m.RegisterModule) 28 27 }, 29 28 { 30 29 path: 'checkMail', ··· 143 142 providers: [{ provide: RouteReuseStrategy, useClass: CustomReuseStrategy }], 144 143 exports: [RouterModule] 145 144 }) 146 - export class AppRoutingModule { } 145 + export class AppRoutingModule {}
+65 -22
packages/frontend/src/app/components/navigation-menu/navigation-menu.component.ts
··· 265 265 // JSON driven UI lmao 266 266 this.menuItems = [ 267 267 { 268 + label: 'menu.register', 269 + icon: faUserPlus, 270 + visible: () => !this.loginService.loggedIn.value, 271 + routerLink: '/register', 272 + command: () => { 273 + this.hideMenu() 274 + } 275 + }, 276 + { 268 277 label: 'menu.login', 269 278 icon: faArrowRightToBracket, 270 279 visible: () => !this.loginService.loggedIn.value, ··· 279 288 divider: true 280 289 }, 281 290 { 282 - label: 'menu.dashboard', 283 - icon: faHouse, 284 - visible: () => this.loginService.loggedIn.value, 285 - routerLink: '/dashboard', 291 + label: 'menu.exploreWafrn', 292 + icon: faCompass, 293 + visible: () => !this.loginService.loggedIn.value, 294 + routerLink: '/dashboard/exploreLocal', 286 295 command: () => { 287 296 this.hideMenu() 288 297 } 289 298 }, 290 299 { 291 - label: 'menu.exploreFediverse', 292 - icon: faCompass, 300 + label: 'menu.dashboard', 301 + icon: faHouse, 293 302 visible: () => this.loginService.loggedIn.value, 294 - routerLink: '/dashboard/explore', 303 + routerLink: '/dashboard', 295 304 command: () => { 296 305 this.hideMenu() 297 306 } 298 307 }, 299 308 { 300 - label: 'menu.notifications', 301 - icon: faBell, 309 + label: 'menu.explore', 310 + icon: faCompass, 302 311 visible: () => this.loginService.loggedIn.value, 303 - badge: () => this.notificationsService.notifications(), 304 - routerLink: '/dashboard/notifications', 305 - command: () => { 306 - this.hideMenu() 307 - } 312 + items: [ 313 + { 314 + label: 'menu.exploreWafrn', 315 + icon: faServer, 316 + visible: () => this.loginService.loggedIn.value, 317 + routerLink: '/dashboard/exploreLocal', 318 + command: () => { 319 + this.hideMenu() 320 + } 321 + }, 322 + { 323 + label: 'menu.exploreFediverse', 324 + icon: faCompass, 325 + visible: () => this.loginService.loggedIn.value, 326 + routerLink: '/dashboard/explore', 327 + command: () => { 328 + this.hideMenu() 329 + } 330 + } 331 + ] 308 332 }, 309 333 { 310 334 label: 'menu.search', ··· 321 345 visible: () => this.loginService.loggedIn.value, 322 346 badge: () => this.inboxNotifications(), 323 347 items: [ 348 + { 349 + label: 'menu.notifications', 350 + icon: faBell, 351 + visible: () => this.loginService.loggedIn.value, 352 + badge: () => this.notificationsService.notifications(), 353 + routerLink: '/dashboard/notifications', 354 + command: () => { 355 + this.hideMenu() 356 + } 357 + }, 324 358 { 325 359 label: 'menu.privateMessages', 326 360 icon: faEnvelope, ··· 579 613 ], 580 614 [ 581 615 { 616 + label: 'menu.register', 617 + icon: faUserPlus, 618 + visible: () => !this.loginService.loggedIn.value, 619 + routerLink: '/register', 620 + command: () => { 621 + this.hideMenu() 622 + } 623 + }, 624 + { 582 625 label: 'menu.login', 583 626 icon: faArrowRightToBracket, 584 627 visible: () => !this.loginService.loggedIn.value, ··· 698 741 699 742 this.menuLinks = [ 700 743 { 701 - label: 'menu.faq', 702 - url: 'https://wafrn.net/faq/overview.html' 744 + label: 'menu.about', 745 + routerLink: '/about' 703 746 }, 704 747 { 705 - label: 'menu.source', 706 - url: 'https://codeberg.org/wafrn/wafrn' 748 + label: 'menu.privacy', 749 + routerLink: '/privacy' 707 750 }, 708 751 { 709 - label: 'menu.status', 710 - url: 'https://uptime.jbc.lol/status/main' 752 + label: 'menu.faq', 753 + url: 'https://wafrn.net/faq/overview.html' 711 754 }, 712 755 { 713 - label: 'menu.sourceModified', 714 - url: 'https://codeberg.org/jbcarreon123/wf.jbc.lol' 756 + label: 'menu.source', 757 + url: 'https://codeberg.org/wafrn/wafrn' 715 758 }, 716 759 { 717 760 label: 'menu.patreon',
+1 -1
packages/frontend/src/app/components/post-actions/post-actions.component.html
··· 37 37 </span> 38 38 </a> 39 39 } 40 - @if (isExternalPost && externalUrl() !== bskyUrl()) { 40 + @if (isExternalPost && (externalUrl() !== bskyUrl())) { 41 41 <a [href]="externalUrl()" target="_blank" mat-menu-item> 42 42 <span class="post-actions-menu-span-content"> 43 43 {{ 'post-actions.viewOriginalPost' | translate }}
+2 -2
packages/frontend/src/app/components/post-actions/post-actions.component.ts
··· 59 59 bookmarked = computed(() => this.post().bookmarkers.includes(this.myId)) 60 60 61 61 bskyUrl = computed<string>(() => { 62 - this.settingsService.settingsModified() // evil fix to update correctly 62 + this.settingsService.settingsModified() // evil fix to update correctly if (!bskyUri) return '' 63 63 const bskyUri = this.post().bskyUri 64 64 if (!bskyUri) return '' 65 65 const parts = bskyUri.split('/app.bsky.feed.post/') 66 66 const userDid = parts[0].split('at://')[1] 67 67 return `https://${this.settingsService.values().atprotoLinkDestination || 'bsky.app'}/profile/${userDid}/post/${parts[1]}` 68 68 }) 69 - externalUrl = computed<string>(() => (this.post().bskyUri ? this.bskyUrl() : this.post().remotePostId)) 69 + externalUrl = computed<string>(() => (this.post().remotePostId ?? this.bskyUrl())) 70 70 71 71 // icons 72 72 shareIcon = faLink
+1 -1
packages/frontend/src/app/guards/is-admin.guard.ts
··· 5 5 export const isAdminGuard: CanActivateFn = (route, state) => { 6 6 const res = inject(JwtService).adminToken() 7 7 if (!res) { 8 - inject(Router).navigate(['/login']) 8 + inject(Router).navigate(['/']) 9 9 } 10 10 return res 11 11 }
+1 -1
packages/frontend/src/app/guards/login-required.guard.ts
··· 5 5 export const loginRequiredGuard: CanActivateChildFn = (childRoute, state) => { 6 6 const res = inject(JwtService).tokenValid() 7 7 if (!res) { 8 - inject(Router).navigate(['/login']) 8 + inject(Router).navigate(['/register']) 9 9 } 10 10 return res 11 11 }
+20 -24
packages/frontend/src/app/pages/login/login.component.html
··· 1 1 <mat-card class="mb-6 lg:mx-4 mat-card-higher wafrn-container"> 2 2 <mat-card-header class="pb-3 wafrn-container-header"> 3 - <mat-card-title class="flex gap-2"><fa-icon [icon]="faArrowRightToBracket"></fa-icon><span class="font-medium">{{ 4 - 'login.title' | translate }}</span></mat-card-title> 3 + <mat-card-title class="flex gap-2" 4 + ><fa-icon [icon]="faArrowRightToBracket"></fa-icon 5 + ><span class="font-medium">{{ 'login.title' | translate }}</span></mat-card-title 6 + > 5 7 </mat-card-header> 6 8 <section class="my-6 mx-3 text-center"> 7 9 <h1 class="text-5xl">{{ 'login.welcomeBack' | translate }}</h1> ··· 10 12 </p> 11 13 </section> 12 14 13 - @if (!isOpenId) { 14 15 <form class="px-3" [formGroup]="loginForm" (ngSubmit)="onSubmit()"> 15 16 <mat-form-field class="mb-1 w-full"> 16 17 <mat-label>{{ 'login.email' | translate }}</mat-label> ··· 20 21 <mat-form-field class="mb-1 w-full mat-form-field-no-padding"> 21 22 <mat-label>{{ 'login.password' | translate }}</mat-label> 22 23 <input formControlName="password" [type]="showPassword ? 'text' : 'password'" name="password" matInput /> 23 - <button mat-icon-button matSuffix type="button" (click)="togglePasswordVisibility()" 24 + <button 25 + mat-icon-button 26 + matSuffix 27 + type="button" 28 + (click)="togglePasswordVisibility()" 24 29 [attr.aria-label]="showPassword ? ('login.hidePassword' | translate) : ('login.showPassword' | translate)" 25 - [attr.aria-pressed]="showPassword"> 30 + [attr.aria-pressed]="showPassword" 31 + > 26 32 <fa-icon [icon]="showPassword ? faEyeSlash : faEye"></fa-icon> 27 33 </button> 28 34 </mat-form-field> ··· 31 37 </div> 32 38 33 39 <div class="flex"> 34 - <button [disabled]="!loginForm.valid" mat-flat-button color="primary" extended 35 - class="w-full border-round-md mt-4"> 40 + <button 41 + [disabled]="!loginForm.valid" 42 + mat-flat-button 43 + color="primary" 44 + extended 45 + class="w-full border-round-md mt-4" 46 + > 36 47 <span class="flex gap-2">{{ 'login.loginButton' | translate }}<fa-icon [icon]="submitIcon"></fa-icon></span> 37 48 </button> 38 49 @if (loginForm.disabled) { 39 - <mat-spinner diameter="24" class="mt-5 mx-3"></mat-spinner> 50 + <mat-spinner diameter="24" class="mt-5 mx-3"></mat-spinner> 40 51 } 41 52 </div> 42 - @if (oidcEnabled) { 43 - <div class="flex"> 44 - <button (click)="redirectToOidc()" type="button" mat-flat-button color="primary" extended 45 - class="w-full border-round-md mt-4"> 46 - <span class="flex gap-2">{{ 'login.oidcButton' | translate:{oidcAuthName} }}<fa-icon 47 - [icon]="submitIcon"></fa-icon></span> 48 - </button> 49 - </div> 50 - } 51 53 </form> 52 - } 53 - @else { 54 - <section class="text-center"> 55 - <h2>{{ 'login.oidcDialog' | translate:{oidcAuthName:oidcAuthName??'OpenID'} }}</h2> 56 - </section> 57 - } 58 54 <section class="p-3 mt-3 wafrn-container-footer"> 59 55 <p class="m-0"> 60 56 {{ 'login.dontHaveAccount' | translate }} 61 57 <a routerLink="/register">{{ 'login.register' | translate }}</a> 62 58 </p> 63 59 </section> 64 - </mat-card> 60 + </mat-card>
-21
packages/frontend/src/app/pages/login/login.component.ts
··· 1 1 import { Component, OnInit } from '@angular/core' 2 2 import { LoginService } from 'src/app/services/login.service' 3 - import { EnvironmentService } from 'src/app/services/environment.service' 4 3 5 4 import { UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms' 6 5 import { MessageService } from 'src/app/services/message.service' ··· 29 28 faEye = faEye 30 29 faEyeSlash = faEyeSlash 31 30 submitIcon = faArrowRight 32 - oidcEnabled = EnvironmentService.environment.oidcEnabled 33 - oidcAuthName = EnvironmentService.environment.oidcAuthName 34 31 35 32 showPassword = false // Property to track password visibility 36 33 ··· 42 39 loginMessage = "you shouldn't see this ever" 43 40 loginReset = false 44 41 45 - isOpenId = window.location.search.includes('openid_token') 46 - 47 42 constructor( 48 43 private loginService: LoginService, 49 44 private messages: MessageService 50 45 ) { 51 46 this.newLoginMessage() 52 - 53 - console.log(EnvironmentService.environment) 54 - if (!this.oidcAuthName || !this.oidcEnabled) { 55 - this.oidcEnabled = EnvironmentService.environment.oidcEnabled 56 - this.oidcAuthName = EnvironmentService.environment.oidcAuthName 57 - this.ngOnInit(); 58 - } 59 - 60 - const searchParams = new URLSearchParams(window.location.search) 61 - if (searchParams.get("openid_token")) { 62 - this.loginService.logInWithOpenId(searchParams.get("openid_token") ?? '', searchParams.get("mfa_required") === "true") 63 - } 64 47 } 65 48 66 49 ngOnInit(): void { ··· 86 69 setTimeout(() => { 87 70 this.loginReset = true 88 71 }, 0) 89 - } 90 - 91 - redirectToOidc() { 92 - window.location.pathname = "/api/login/oidc" 93 72 } 94 73 95 74 async onSubmit() {
+42 -21
packages/frontend/src/app/pages/register/register.component.html
··· 1 1 <mat-card class="pb-3 mb-6 lg:mx-4 mat-card-higher wafrn-container"> 2 2 <mat-card-header class="pb-3 wafrn-container-header"> 3 - <mat-card-title class="flex gap-2"><fa-icon [icon]="faUserPlus"></fa-icon><span class="font-medium">{{ 4 - 'register.title' | translate }}</span></mat-card-title> 3 + <mat-card-title class="flex gap-2" 4 + ><fa-icon [icon]="faUserPlus"></fa-icon 5 + ><span class="font-medium">{{ 'register.title' | translate }}</span></mat-card-title 6 + > 5 7 </mat-card-header> 6 8 <section class="my-6 mx-3 text-center"> 7 9 <h1 class="mb-0 text-5xl">{{ 'register.welcome' | translate }}</h1> 8 10 </section> 9 11 10 12 @if (manuallyReview) { 11 - <section class="mx-3 mb-4"> 12 - <app-info-card type="info">{{ 'register.manualReviewInfo' | translate }}</app-info-card> 13 - </section> 13 + <section class="mx-3 mb-4"> 14 + <app-info-card type="info">{{ 'register.manualReviewInfo' | translate }}</app-info-card> 15 + </section> 14 16 } 15 17 16 18 <form class="px-3" [formGroup]="loginForm" (ngSubmit)="onSubmit()"> 17 19 <mat-form-field class="w-full"> 18 20 <mat-label>Email</mat-label> 19 - <input formControlName="email" type="email" matInput [(ngModel)]="email" /> 21 + <input formControlName="email" type="email" matInput /> 20 22 </mat-form-field> 21 23 22 24 <mat-form-field class="w-full"> ··· 30 32 31 33 <mat-form-field class="w-full mb-3" subscriptSizing="dynamic"> 32 34 <mat-label>Username</mat-label> 33 - <input formControlName="url" type="text" matInput [(ngModel)]="username" /> 35 + <input formControlName="url" type="text" matInput /> 34 36 <mat-hint>Right now we do not allow special characters or spaces</mat-hint> 35 37 <mat-error>Username contains invalid characters</mat-error> 36 38 </mat-form-field> ··· 43 45 44 46 <mat-form-field (click)="picker.open()" class="w-full mb-3" subscriptSizing="dynamic"> 45 47 <mat-label>Your birth date <b>(There is a minimum registration age!)</b></mat-label> 46 - <input formControlName="birthDate" matInput [min]="minDate" [max]="minimumRegistrationDate" 47 - [matDatepicker]="picker" /> 48 - <mat-hint>MM/DD/YYYY - Your birthday date is required for legal reasons in the european union and USA. Its not 49 - shared 50 - with anyone. Minimum registration age varies depending on the instance</mat-hint> 48 + <input 49 + formControlName="birthDate" 50 + matInput 51 + [min]="minDate" 52 + [max]="minimumRegistrationDate" 53 + [matDatepicker]="picker" 54 + /> 55 + <mat-hint 56 + >MM/DD/YYYY - Your birthday date is required for legal reasons in the european union and USA. Its not shared 57 + with anyone. Minimum registration age varies depending on the instance</mat-hint 58 + > 51 59 <mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle> 52 60 <mat-datepicker #picker></mat-datepicker> 53 61 </mat-form-field> ··· 56 64 <mat-hint>This actually does not do anything at all</mat-hint> 57 65 <mat-select> 58 66 @for (gender of genders; track $index) { 59 - <mat-option [value]="gender" [innerHTML]="gender">{{ gender }}</mat-option> 67 + <mat-option [value]="gender" [innerHTML]="gender">{{ gender }}</mat-option> 60 68 } 61 69 </mat-select> 62 70 </mat-form-field> ··· 66 74 <button type="button" mat-stroked-button color="primary" (click)="avatarInput.click()"> 67 75 <fa-icon [icon]="faUpload" class="m-1"></fa-icon>Choose File 68 76 </button> 69 - <input #avatarInput formControlName="avatar" id="avatar" type="file" 70 - accept="image/jpeg,image/png,image/webp,image/gif" hidden (change)="imgSelected($event)" /> 77 + <input 78 + #avatarInput 79 + formControlName="avatar" 80 + id="avatar" 81 + type="file" 82 + accept="image/jpeg,image/png,image/webp,image/gif" 83 + hidden 84 + (change)="imgSelected($event)" 85 + /> 71 86 <p class="m-auto ml-1">{{ selectedFileName }}</p> 72 87 </div> 73 88 </div> 74 89 75 90 <div class="flex"> 76 - <button color="primary" mat-flat-button extended [disabled]="!loginForm.valid" 77 - class="w-full border-round-md mt-4"> 78 - <span class="flex gap-2">{{ 'register.registerButton' | translate }} <fa-icon 79 - [icon]="submitIcon"></fa-icon></span> 91 + <button 92 + color="primary" 93 + mat-flat-button 94 + extended 95 + [disabled]="!loginForm.valid" 96 + class="w-full border-round-md mt-4" 97 + > 98 + <span class="flex gap-2" 99 + >{{ 'register.registerButton' | translate }} <fa-icon [icon]="submitIcon"></fa-icon 100 + ></span> 80 101 </button> 81 102 @if (loginForm.disabled) { 82 - <mat-spinner diameter="24" class="mt-5 mx-3"></mat-spinner> 103 + <mat-spinner diameter="24" class="mt-5 mx-3"></mat-spinner> 83 104 } 84 105 </div> 85 106 </form> 86 - </mat-card> 107 + </mat-card>
+2 -12
packages/frontend/src/app/pages/register/register.component.ts
··· 5 5 6 6 import { faArrowRight, faEye, faEyeSlash, faUpload, faUserPlus } from '@fortawesome/free-solid-svg-icons' 7 7 import { EnvironmentService } from 'src/app/services/environment.service' 8 - import { ActivatedRoute, Router } from '@angular/router' 8 + import { Router } from '@angular/router' 9 9 10 10 @Component({ 11 11 selector: 'app-register', ··· 270 270 avatar: new UntypedFormControl('', []) 271 271 }) 272 272 273 - searchParams = new URLSearchParams(window.location.search) 274 - email = this.searchParams.get('email') 275 - username = this.searchParams.get('username') 276 - 277 273 constructor( 278 274 private loginService: LoginService, 279 275 private messages: MessageService, 280 - private router: Router, 281 - private route: ActivatedRoute 276 + private router: Router 282 277 ) { 283 - this.route.queryParams.subscribe(params => { 284 - this.email = params['email'] 285 - this.username = params['username'] 286 - }) 287 - 288 278 // minimum age: 14 289 279 this.minimumRegistrationDate = new Date() 290 280 this.minimumRegistrationDate.setFullYear(this.minimumRegistrationDate.getFullYear() - 18)
+3 -19
packages/frontend/src/app/services/login.service.ts
··· 94 94 return success 95 95 } 96 96 97 - async logInWithOpenId(token: string, mfaRequired = false): Promise<boolean> { 98 - let success = false 99 - if (mfaRequired) { 100 - success = true 101 - // HACK DO NOT TOUCH THE ASYNC. IM SERIOUS. IT WOULD SKIP THIS SCREEN 102 - // IF YOU TOUCH THIS CODE YOU NEED TO TEST LOGIN WITH AN MFA ACC 103 - await this.router.navigate(['/login/mfa']) 104 - await this.addToken(token) 105 - } else { 106 - await this.addToken(token) 107 - await this.handleSuccessfulLogin() 108 - await this.router.navigate(['/dashboard']) 109 - success = true 110 - } 111 - 112 - return success 113 - } 114 - 115 97 async logInMfa(loginMfaForm: UntypedFormGroup): Promise<boolean> { 116 98 let success = false 117 99 try { ··· 368 350 hideQuotes: 'wafrn.hideQuotes', 369 351 displayMentionsOfBlockedUsersFromOtherUsers: 'wafrn.displayMentionsOfBlockedUsersFromOtherUsers', 370 352 hideNoDescriptionMedia: 'wafrn.hideNoDescriptionMedia', 371 - disableRewootsExploreLocal: 'wafrn.disableRewootsExploreLocal' 353 + disableRewootsExploreLocal: 'wafrn.disableRewootsExploreLocal', 354 + disableRewootsDashboard: 'wafrn.disableRewootsDashboard', 355 + disableReplies: 'wafrn.disableReplies' 372 356 } 373 357 374 358 try {
+21 -1
packages/frontend/src/app/services/settings.service.ts
··· 84 84 'confettiMultiplier', 85 85 'flatConfetti', 86 86 'disableRewootsExploreLocal', 87 + 'disableRewootsDashboard', 88 + 'disableReplies', 87 89 'confirmOpenCw', 88 90 'confirmOpenCwAnnoyance', 89 91 'disableLinkPreviews' ··· 403 405 type: 'checkbox', 404 406 default: false 405 407 }, 408 + disableRewootsDashboard: { 409 + key: 'disableRewootsDashboard', 410 + translationKey: 'settings.disableRewootsDashboard', 411 + serverKey: 'wafrn.disableRewootsDashboard', 412 + localStorageKey: 'disableRewootsDashboard', 413 + type: 'checkbox', 414 + default: false 415 + }, 416 + disableReplies: { 417 + key: 'disableReplies', 418 + translationKey: 'settings.disableReplies', 419 + serverKey: 'wafrn.disableReplies', 420 + localStorageKey: 'disableReplies', 421 + type: 'checkbox', 422 + default: false 423 + }, 406 424 automaticallyExpandPosts: { 407 425 key: 'automaticallyExpandPosts', 408 426 translationKey: 'settings.automaticallyExpandPosts', ··· 698 716 { type: 'header', value: 'settings.header.dashboardBehavior' }, 699 717 { type: 'key', value: 'defaultDashboard' }, 700 718 { type: 'key', value: 'disableRewootsExploreLocal' }, 719 + { type: 'key', value: 'disableRewootsDashboard' }, 720 + { type: 'key', value: 'disableReplies' }, 701 721 { type: 'key', value: 'automaticallyExpandPosts' }, 702 722 { type: 'key', value: 'expandQuotes' }, 703 723 { type: 'key', value: 'disableLinkPreviews' }, ··· 826 846 try { 827 847 this.fediAttachments.length = 0 828 848 this.fediAttachments.push(...JSON.parse(rawAttachments.optionValue)) 829 - } catch (error) {} 849 + } catch (error) { } 830 850 831 851 if (this.fediAttachments.length === 0) { 832 852 this.fediAttachments.push({ name: '', value: '' })
packages/frontend/src/assets/Censor_Mb 2.png

This is a binary file and will not be displayed.

packages/frontend/src/assets/Desktop - 1.png

This is a binary file and will not be displayed.

+4 -1
packages/frontend/src/assets/i18n/br.json
··· 370 370 "instanceOnly": "Somente Local", 371 371 "unlisted": "Não Listado" 372 372 }, 373 + "disableRewootsExploreLocal": "Desabilitar rewoots no feed Explorar Local", 374 + "disableRewootsDashboard": "Desabilitar rewoots na Linha do Tempo", 375 + "disableReplies": "Desabilitar respostas nos feeds", 373 376 "mutedWords": "Palavras silenciadas", 374 377 "mutedWordsDescription": "Uma por linha. Woots com estas frases serão escondidos por trás de um aviso de conteúdo.", 375 378 "superMutedWords": "Palavras bloqueadas", ··· 573 576 "couldNotFind": "não leva a uma página", 574 577 "returnHome": "Ir à página inicial" 575 578 } 576 - } 579 + }
+15 -16
packages/frontend/src/assets/i18n/en.json
··· 25 25 "showPassword": "Show password", 26 26 "hidePassword": "Hide password", 27 27 "loginButton": "Log in", 28 - "oidcButton": "Log in with {{ oidcAuthName }}", 29 - "oidcDialog": "Logging in with {{ oidcAuthName }}, please wait...", 30 28 "checkEmail": "Please check your email", 31 29 "checkEmailLine1": "We have sent you an email. Please do check your email and the spam folder", 32 30 "checkEmailLine2": "If you have any problem, write to the instance admin, whose email can be found <a href=\"/about\">here</a>", ··· 56 54 "writeWoot": "Woot", 57 55 "notifications": "Notifications", 58 56 "explore": "Explore", 59 - "exploreWafrn": "Explore wf.jbc.lol", 60 - "exploreFediverse": "wf.jbc.lol & friends", 57 + "exploreWafrn": "Explore WAFRN", 58 + "exploreFediverse": "WAFRN & friends", 61 59 "unansweredAsks": "Unanswered Asks", 62 60 "privateMessages": "Private messages", 63 61 "search": "Search", ··· 65 63 "myBlog": "My blog", 66 64 "about": "About", 67 65 "privacy": "Privacy", 68 - "source": "Source (original)", 69 - "status": "Status", 70 - "sourceModified": "Source", 66 + "source": "Source", 71 67 "patreon": "Patreon", 72 68 "kofi": "Ko-fi", 73 69 "inbox": "Inbox", ··· 79 75 "faq": "FAQ", 80 76 "more": "More", 81 77 "silencedPosts": "Silenced Woots", 78 + "bookmarkedPosts": "Bookmarked woots", 82 79 "settings": { 83 80 "title": "Settings", 84 81 "follows": "Manage followers", ··· 205 202 "notRecommendedOptions": "Not recommended options", 206 203 "disableNSFWFilter": "Disable NSFW images filter", 207 204 "automaticallyExpandAllPosts": "Automatically expand all woots", 208 - "displayMentionsOfBlockedUsersFromOtherUsers": "Do not hide woots containing mentions to users in the shadow realm", 205 + "displayMentionsOfBlockedUsersFromOtherUsers": "Do not hide woots containing mentions to blocked users", 209 206 "hideNoDescriptionMedia": "Automatically CW media with no alt text" 210 207 }, 211 208 "privacy": { ··· 370 367 "hideProfileNotLoggedIn": "Hide profile to users who are not logged in", 371 368 "hideProfileNotLoggedInDescription": "Only applies to your profile in this wafrn, not to your woots nor other fedi instances or bluesky.", 372 369 "hideFollows": "Hide my follows and followers", 373 - "displayMentionsOfBlockedUsersFromOtherUsers": "Show woots containing mentions to users in the shadow realm", 370 + "displayMentionsOfBlockedUsersFromOtherUsers": "Show woots containing mentions to blocked users", 374 371 "defaultPostEditorPrivacy": "Default Woot Editor Privacy", 375 372 "postEditorPrivacyOptions": { 376 373 "public": "Public", ··· 379 376 "unlisted": "Unlisted" 380 377 }, 381 378 "disableRewootsExploreLocal": "Disable rewoots in Explore local feed", 379 + "disableRewootsDashboard": "Disable rewoots in Dashboard", 380 + "disableReplies": "Disable replies in feeds", 382 381 "mutedWords": "Muted words", 383 382 "mutedWordsDescription": "One word per line. Woots with these phrases will be placed behind a CW.", 384 383 "superMutedWords": "Blocked words", ··· 451 450 "silenceReplyDescription": "Silence notifications from likes, rewoots, reacts, and replies of this woot.", 452 451 "muteAccountTitle": "Mute account", 453 452 "muteAccountDescription": "Hide woots and replies by this user. They will still be able to interact with your woots.", 454 - "blockAccountTitle": "Send user to the shadow realm", 453 + "blockAccountTitle": "Block account", 455 454 "muteAccountLabel": "Reason (optional)", 456 455 "unmuteAccountTitle": "Unmute account", 457 456 "unmuteAccountDescription": "Woots and replies by this user will be shown again.", ··· 560 559 "bookmarkPost": "Bookmark woot", 561 560 "unbookmarkPost": "Unbookmark woot", 562 561 "likePost": "Like woot", 563 - "dislikePost": "Remove woot", 562 + "dislikePost": "Remove like", 564 563 "reportPost": "Report woot", 565 564 "editPost": "Edit woot", 566 565 "deletePost": "Delete woot", ··· 569 568 "unsilenceReplyNotifications": "Unsilence woot", 570 569 "muteUser": "Mute user", 571 570 "unmuteUser": "Unmute user", 572 - "blockUser": "Send user to the shadow realm", 573 - "unblockUser": "Retrieve user from the shadow realm", 571 + "blockUser": "Block user", 572 + "unblockUser": "Unblock user", 574 573 "reportUser": "Report user", 575 - "bitePost": "Bite post", 574 + "bitePost": "Bite woot", 576 575 "biteUser": "Bite user" 577 576 }, 578 577 "post-header": { ··· 628 627 }, 629 628 "typing": { 630 629 "instructions": "Type the following phrase:", 631 - "verifyLabel": "Verify..." 630 + "verifyLabel": "Here..." 632 631 } 633 632 } 634 - } 633 + }
+260 -9
packages/frontend/src/assets/i18n/pl.json
··· 1 1 { 2 + "common": { 3 + "commaSeparation": "rozdzielone przecinkami", 4 + "characters": "znaków", 5 + "default": "Domyślne", 6 + "commit": "commit" 7 + }, 2 8 "login": { 3 9 "welcomeBack": "Witaj z powrotem!", 4 10 "exploreWithoutAccount": "Kliknij tutaj, by przejrzeć Gofra bez konta!", ··· 12 18 "forgottenPassword": "Zapomniałoś hasła? Kliknij tutaj!", 13 19 "loginButton": "Zaloguj się" 14 20 }, 21 + "register": { 22 + "title": "Zarejestruj się teraz!", 23 + "welcome": "Witaj w Wafrn!", 24 + "manualReviewInfo": "Administracja zewalułuje twój profil po rejestracji. Może to potrwać kilka godzin", 25 + "registerButton": "Zarejestruj się" 26 + }, 27 + "notifications": { 28 + "title": "Powiadomienia", 29 + "loadingMore": "Ładowanie więcej powiadomień", 30 + "refresh": "Odśwież" 31 + }, 32 + "asks": { 33 + "title": "Zapytania", 34 + "noAsks": "Nie ma więcej zapytań", 35 + "nobodyAsked": "Nikt nie pytał..." 36 + }, 15 37 "menu": { 16 38 "dashboard": "Oś czasu", 17 39 "dashboardHover": "Zobacz oś czasu", 18 - "writeWoot": "Stwórz woota", 40 + "writeWoot": "Stwórz Woota", 19 41 "notifications": "Powiadomienia", 20 42 "explore": "Eksploruj", 21 - "exploreWafrn": "Eksploruj Gofra", 43 + "exploreWafrn": "Eksploruj Wafrn", 22 44 "exploreFediverse": "Eksploruj Fediwersum", 23 45 "unansweredAsks": "Pytania bez odpowiedzi", 24 46 "privateMessages": "Wiadomości prywatne", ··· 30 52 "patreon": "Patreon", 31 53 "kofi": "Ko-fi", 32 54 "logout": "Wyloguj się", 55 + "bookmarkedPosts": "Zapisane wooty", 33 56 "settings": { 34 57 "title": "Ustawienia", 35 58 "follows": "Zarządzaj obserwującymi", 36 - "enableBluesky": "Włącz mostek z Bluesky", 59 + "enableBluesky": "Włącz połączenie z Bluesky", 37 60 "editProfile": "Edytuj profil", 38 61 "themeEditor": "Edytor motywu", 39 62 "mutedUsers": "Wyciszeni użytkownicy", 40 63 "mutedPosts": "Wyciszone posty", 41 - "bookmarkedPosts": "Bookmarked posts [TRANSLATION PENDING]", 64 + "bookmarkedPosts": "Zapisane wooty", 42 65 "myBlockedUsers": "Zablokowani użytkownicy", 43 66 "myBlockedServers": "Zablokowane serwery", 44 67 "importFollows": "Importuj obserwujących", 45 - "superSecretMenu": "Seekretne Menu" 68 + "superSecretMenu": "Sekretne menu" 46 69 }, 47 70 "admin": { 48 71 "title": "Administracja", ··· 55 78 "awaitingAproval": "Oczekujący na akceptację" 56 79 } 57 80 }, 81 + "theme-editor": { 82 + "title": "Edytor Motywów", 83 + "templateNote": "Możesz zobaczyć przykłady i kilka wskazówek w", 84 + "themeTextLabel": "Edytuj własny motyw CSS", 85 + "themeTextPlaceholder": "Wpisz tutaj własny motyw CSS...", 86 + "themeGuideName": "Samouczku Motywów", 87 + "updateButton": "Zaktualizuj Motyw" 88 + }, 58 89 "editor": { 59 90 "inReplyTo": "W odpowiedzi do:", 60 91 "quoteButton": "Zacytuj woota", ··· 80 111 "uploadMediaTooltip": "Dodaj media", 81 112 "contentWarningTooltip": "Ostrzeżenie o treściach wrażliwych" 82 113 }, 114 + "blog": { 115 + "tabWoots": "Wooty", 116 + "tabMedia": "Multimedia" 117 + }, 83 118 "profile": { 84 119 "security": { 85 120 "header": "Bezpieczeństwo", ··· 97 132 "installInstructions": "Jeśli nie masz aplikacji uwierzytelniającej, zalecamy użycie <a href=\"https://getaegis.app/\" target=\"_blank\">Aegis</a> (Android) lub <a href=\"https://2fas.com/\" target=\"_blank\">2FAS</a> (iOS/Android)", 98 133 "imageAltSecret": "Sekretny kod do uwierzytelnienia to '{{ secret }}'", 99 134 "secretFallback": "Jeśli nie możesz zeskanować kodu QR, użyj sekretnego kodu '{{ secret }}' w swojej aplikacji", 100 - "confirmDeleteMessage": "Czy napewno chcesz usunąć ten token? Uwaga: Usunięcie wszystkich tokenów wyłączy MFA na twoim koncie", 135 + "confirmDeleteMessage": "Czy na pewno chcesz usunąć ten token? Uwaga: Usunięcie wszystkich tokenów wyłączy MFA na twoim koncie", 101 136 "deleteSuccess": "Token usunięty", 102 137 "verifySuccess": "Gratulacje, MFA zostało włączone na twoim koncie", 103 138 "verifyFailed": "Weryfikacja nieudana. Sprawdź swój token, a jeśli dalej masz problem, skontaktuj się z administracją", 104 139 "errorMessageGeneric": "Wystąpił błąd. Spróbuj ponownie, lub poproś administrację o pomoc", 105 140 "noMfa": "MFA nie jest włączone na twoim koncie", 106 - "mfaList": "Ustawiłoś poniższe tokeny:", 141 + "mfaList": "Ustawione zostały poniższe tokeny:", 107 142 "type": { 108 143 "totpLabel": "Token" 109 144 } ··· 116 151 "misc": "Inne" 117 152 } 118 153 }, 154 + "settings": { 155 + "sidebar": { 156 + "profile": "Profil", 157 + "account": "Konto", 158 + "appearance": "Wygląd", 159 + "behavior": "Zachowanie", 160 + "privacy": "Prywatność", 161 + "mutesAndBlocks": "Wyciszenia i Blokady", 162 + "miscellaneous": "Inne" 163 + }, 164 + "header": { 165 + "fediAttachments": "Atrybuty w Fediwersum", 166 + "emailAndPassword": "Email i hasło", 167 + "integrations": "Integracje", 168 + "migration": "Migracja konta", 169 + "deleteAccount": "Usuwanie Konta", 170 + "appearance": "Wygląd", 171 + "userInterface": "Interfejs", 172 + "classicOptions": "Opcje klasyczne", 173 + "animationsAndSounds": "Animacje i dźwięki", 174 + "dashboardBehavior": "Zachowanie osi czasu", 175 + "cwBehavior": "Zachowanie treści oznaczonych jako nieprzyzwoite", 176 + "profilePrivacy": "Prywatność profilu", 177 + "editor": "Edytor", 178 + "followers": "Obserwujący", 179 + "mutedBlockedWords": "Zablokowane i wyciszone słowa", 180 + "blockBehavior": "Zachowanie blokowania", 181 + "mutedBlockedUsers": "Zablokowani i wyciszeni użytkownicy", 182 + "fun": "Frajda" 183 + }, 184 + "fediPropertyKey": "Nazwa atrybutu", 185 + "fediPropertyValue": "Wartość atrybutu", 186 + "addFediAttachment": "Dodaj atrybut", 187 + "changeAvatar": "Zmień awatar", 188 + "changeHeaderImage": "Zmień baner", 189 + "name": "Nazwa", 190 + "description": "Opis", 191 + "changePasswordTitle": "Rozwiń aby zmienić hasło", 192 + "changePassword": "Zmień hasło", 193 + "changePasswordDescription": "Link do zmiany hasła został wysłany na twój adres email.", 194 + "changePasswordMessage": "Link do zmiany hasła został wysłany na twój adres email.", 195 + "disableEmailNotifications": "Wyłącz powiadomienia email.", 196 + "rssOptions": "Włącz RSS i microfront dla twojego bloga", 197 + "rssOptionsOptions": { 198 + "none": "Brak", 199 + "articles": "Artykuły (opcja w budowie)", 200 + "all": "Wszystko" 201 + }, 202 + "alsoKnownAs": "[FIXME] Znany też jako", 203 + "requestAccountDeletion": "Rozwiń aby usunąć konto", 204 + "alsoKnownAsDescription": "Migracja z innego konta (nie pisz @)", 205 + "theme": "Motyw", 206 + "themeDescription": "Motywy zmieniają kolory, niektóre wpływają na ciemny i jasny tryb.", 207 + "lightDarkMode": "Tryb Jasny/Ciemny ", 208 + "lightDarkModeDescription": "Tryb Jasny/Ciemny. Niektóre motywy nie działają z tymi trybami", 209 + "additionalStyleModes": "Dodatkowa stylizacja", 210 + "additionalStyleModesDescription": "Dodatkowe opcje dla układu oraz klasycznego interfejsu.", 211 + "useOtherUserCustomThemes": "Używaj motywów innych blogów", 212 + "useOtherUserCustomThemesDescription": "Używaj motywów CSS które zostały zdefiniowane w innych blogach. Własne motywy nie są moderowane i mogą zawierać elementy migoczące", 213 + "askToUseOtherUserCustomThemes": "Pytaj czy używać motywów innych blogów", 214 + "askToUseOtherUserCustomThemesDescription": "Otrzymasz pytanie jeśli wejdziesz na bloga z własnym motywem.", 215 + "uiLanguage": "Język interfejsu użytkownika", 216 + "uiLanguageDescription": "Wafrn jest tłumaczony przez wolontariuszy. Sprawdź <a href=\"https://codeberg.org/wafrn/wafrn\">kod źródłowy</a> w jaki sposób możesz pomóc!", 217 + "forceClassicLogo": "Wymuś klasyczne logo", 218 + "forceClassicVideoPlayer": "Wymuś klasyczny odtwarzacz wideo", 219 + "forceClassicAudioPlayer": "Wymuś klasyczny odtwarzacz dźwięków", 220 + "forceClassicMediaView": "Wymuś klasyczny odtwarzacz obrazów", 221 + "disableConfetti": "Wyłącz konfetti gdy klikasz niektóre przyciski", 222 + "disableConfettiDescription": "Domyślnie konfetti wybucha podczas polubień, reakcji, wootowania, edycji i podbić.", 223 + "enableConfettiReceivingLike": "Włącz konfetti gdy otrzymasz polubienie (PvP)", 224 + "disableSounds": "Wyłącz dźwięk gdy klikasz niektóre przyciski", 225 + "disableSoundsDescription": "Domyślnie dźwięki odtwarzają się podczas polubień, reakcji, wootowania, edycji i podbić.", 226 + "defaultDashboard": "Domyślna strona główna", 227 + "defaultDashboardOptions": { 228 + "dashboard": "Oś czasu", 229 + "exploreLocal": "Eksploruj Wafrn" 230 + }, 231 + "automaticallyExpandPosts": "Automatycznie rozwiń wooty", 232 + "expandQuotes": "Nie zwijaj cytatów", 233 + "atprotoLinkDestination": "Przekierowanie linków Atproto", 234 + "atprotoLinkDestinationDescription": "Strona gdzie zostaną wyświetlone wooty Atproto. Domyślnie jest 'bsky.app'.", 235 + "disableCW": "Wyłącz ostrzeżenia przed treściami nieprzyzwoitymi", 236 + "disableNSFWFilter": "Wyłącz filtr multimediów oznaczonych jako nieprzyzwoite", 237 + "hideNoDescriptionMedia": "Ukryj za filtrem multimedia bez tekstu alternatywnego", 238 + "disableForceAltText": "Zezwól na przesyłanie multimediów bez tekstu alternatywnego", 239 + "disableForceAltTextDescription": "Włącz tę opcję jeśli nie masz RiGCz-u", 240 + "manuallyAcceptsFollows": "Ręcznie akceptuj obserwujących", 241 + "enableAsks": "Włącz zapytania", 242 + "enableAnonymousAsks": "Włącz anonimowe zapytania", 243 + "hideProfileNotLoggedIn": "Ukryj twój profil dla niezalogowanych", 244 + "hideProfileNotLoggedInDescription": "Dotyczy tylko tego Wafrna. Niezalogowani mogą zobaczyć twoje wooty. Nie dotyczy to innych instancji oraz blueskaja", 245 + "hideFollows": "Ukryj obserwujących i obserwowanych ", 246 + "displayMentionsOfBlockedUsersFromOtherUsers": "Pokaż wooty ze wzmiankami zablokowanych użytkowników", 247 + "defaultPostEditorPrivacy": "Domyślna wartość prywatności w edytorze", 248 + "postEditorPrivacyOptions": { 249 + "public": "Publiczny", 250 + "followersOnly": "Tylko obserwujący", 251 + "instanceOnly": "Tylko ta instancja", 252 + "unlisted": "Niepubliczne" 253 + } 254 + }, 255 + "dialog": { 256 + "confirm": "Potwierdź", 257 + "cancel": "Anuluj" 258 + }, 119 259 "ask-dialog-content": { 120 - "askAnonymously": "Anonimowe pytanie", 260 + "askAnonymously": "Zadaj pytanie anonimowo", 121 261 "askFormLabel": "Pytanie", 122 262 "askButtonText": "Zapytaj" 263 + }, 264 + "messages": { 265 + "deleteRewootSuccess": "Podbicie zostało usunięte.", 266 + "likePostSuccess": "Woot został polubiony.", 267 + "unlikePostSuccess": "Polubienie zostało usunięte.", 268 + "bookmarkPostSuccess": "Woot został zapisany.", 269 + "unbookmarkPostSuccess": "Woot został usunięty z zapisanych.", 270 + "rewootPostSuccess": "Woot został podbity.", 271 + "followMessageSuccess": "Obserwujesz tego użytkownika.", 272 + "cancelFollowMessageSuccess": "Anulowano prośbę o obserwację użytkownika.", 273 + "unfollowMessageSuccess": "Nie obserwujesz już tego użytkownika.", 274 + "copyLocalLinkSuccess": "Skopiowano link do schowka.", 275 + "copyRemoteLinkSuccess": "Skopiowano link zewnętrzny do schowka.", 276 + "silencePostSuccess": "Wyciszono powiadomienia dla tego woota.", 277 + "unsilencePostSuccess": "Będziesz otrzymywać powiadomienia dla tego woota.", 278 + "blockUserSuccess": "Zablokowano tego użytkownika.", 279 + "unblockUserSuccess": "Odblokowano tego użytkownika.", 280 + "muteUserSuccess": "Wyciszono tego użytkownika.", 281 + "unmuteUserSuccess": "Odciszono użytkownika.", 282 + "blockServerSuccess": "Zablokowano server.", 283 + "reportUserSuccess": "Stworzono raport dla tego użytkownika.", 284 + "reportPostSuccess": "Stworzono raport dla tego woota.", 285 + "genericError": "Coś poszło nie tak! Sprawdź czy masz połączenie internetowe. ", 286 + "notRewootableError": "Nie udało się podbić woota, podbicia wyłączone przez autora.", 287 + "bitePostSuccess": "Woot został ugryziony!", 288 + "biteUserSuccess": "Użytkownik został ugryziony!" 289 + }, 290 + "keyboard-shortcuts": { 291 + "keyboardShortcuts": "Skróty Klawiszowe", 292 + "groupNavigation": "Nawigacja", 293 + "scrollDown": "Przewiń w dół", 294 + "scrollUp": "Przewiń w górę", 295 + "scrollDownPage": "Przewiń w dół o stronę", 296 + "scrollUpPage": "Przewiń w górę o stronę", 297 + "nextPost": "Następny woot", 298 + "previousPost": "Poprzedni woot", 299 + "groupPostAction": "Akcje Wootów", 300 + "likePost": "Polub woot", 301 + "rewootPost": "Podbij woot", 302 + "replyPost": "Odpowiedz na woot", 303 + "quotePost": "Zacytuj woota", 304 + "groupMisc": "Inne", 305 + "openEditor": "Otwórz edytor", 306 + "viewKeyboardShortcuts": "Zobacz skróty klawiszowe", 307 + "unbound": "nieprzypisany", 308 + "additionalOptions": "Dodatkowe Opcje", 309 + "smoothScroll": "Płynne przewijanie" 310 + }, 311 + "post-actions": { 312 + "shareUrl": "Skopjuj link", 313 + "shareExternalUrl": "Skopjuj link zewnętzrzny", 314 + "viewOnAtproto": "Zobacz na Atproto", 315 + "forceRefederate": "Wymuś ponowną federację", 316 + "viewOriginalPost": "Zobacz orginał", 317 + "replyPost": "Odpowiedz na woota", 318 + "rewootPost": "Podbij woota", 319 + "deleteRewootPost": "Cofnij Podbicie", 320 + "quotePost": "Cytuj woota", 321 + "bookmarkPost": "Zapisz woota", 322 + "unbookmarkPost": "Usuń z zapisanych", 323 + "likePost": "Polub woota", 324 + "dislikePost": "Cofnij polubienie", 325 + "reportPost": "Zgłoś woota", 326 + "editPost": "Edytuj woota", 327 + "deletePost": "Usuń woota", 328 + "silenceInteractions": "Wycisz powiadomienia", 329 + "silenceReplyNotifications": "Wycisz odpowiedzi", 330 + "unsilenceReplyNotifications": "Odcisz odpowiedzi", 331 + "muteUser": "Wycisz użytkownika", 332 + "unmuteUser": "Odcisz użytkownika", 333 + "blockUser": "Zablokuj użytkownika", 334 + "unblockUser": "Odblokuj użytkownika", 335 + "reportUser": "Zgłoś użytkownika", 336 + "bitePost": "Ugryź woota", 337 + "biteUser": "Ugryź użytkownika" 338 + }, 339 + "post-header": { 340 + "follow": "Obserwuj", 341 + "awaitingApproval": "Oczekiwanie na akceptację", 342 + "viewPost": "Wyświetl woot" 343 + }, 344 + "post-fragment": { 345 + "mutedWords": "Woot zawiera zablokowane słowa:", 346 + "mediaContentWarning": "Woot zawiera ostrzerzenie:", 347 + "mediaNsfw": "Multimedia oznaczone są jako nieprzyzwoite", 348 + "replyingTo": "Wzmianki: " 349 + }, 350 + "media": { 351 + "noAltText": "Brak tekstu alternatywnego", 352 + "sensitiveContent": "Te multimedia zawierają niestosowne treści", 353 + "manuallyHidden": "Multimedia zostały ręcznie ukryte" 354 + }, 355 + "page-not-found": { 356 + "title": "404: Nie znaleziono strony", 357 + "subtitle": "Niczego tutaj nie ma.", 358 + "couldNotFind": "nie prowadzi do poprawnej strony", 359 + "returnHome": "Wróć do strony głównej" 360 + }, 361 + "games": { 362 + "common": { 363 + "moves": "Ruchów" 364 + }, 365 + "fifteen": { 366 + "title": "Piętnastka", 367 + "newBoard": "Nowa Plansza", 368 + "resetBoard": "Zresetuj Planszę" 369 + }, 370 + "typing": { 371 + "instructions": "Wpisz podaną frazę:", 372 + "verifyLabel": "Tutaj..." 373 + } 123 374 } 124 - } 375 + }
+1 -1
packages/frontend/src/assets/i18n/ru.json
··· 572 572 "couldNotFind": "не ведёт к странице", 573 573 "returnHome": "Вернуться на главную" 574 574 } 575 - } 575 + }