From 5d34b387e858c9a1b3fe4a20399e63547b9f418c Mon Sep 17 00:00:00 2001 From: aggie Date: Wed, 3 Jun 2026 22:59:03 -0400 Subject: [PATCH] initial --- .editorconfig | 12 + .env.example | 18 + .github/workflows/ci.yml | 92 + .gitignore | 21 + ARCHITECTURE.md | 121 + CONTRIBUTING.md | 65 + LICENSE | 674 ++++++ NOTICE | 29 + PRIVACY.md | 72 + README.md | 141 ++ SECURITY.md | 51 + deploy/hetzner/cloud-init.yml | 128 ++ deploy/hetzner/firewall-rules.json | 23 + deploy/hetzner/lib.sh | 74 + deploy/hetzner/production.env.example | 43 + deploy/hetzner/provision.sh | 202 ++ deploy/hetzner/setup-github.sh | 89 + deploy/hetzner/update.sh | 68 + docs/dependency-policy.md | 22 + docs/deployment/hetzner-cloud-cli.md | 240 ++ docs/deployment/hetzner-cloud.md | 232 ++ docs/local-development.md | 61 + docs/self-hosting.md | 145 ++ docs/skin-examples/black-and-grey.html | 146 ++ docs/skin-examples/cd-case-profile-photo.html | 72 + docs/skin-examples/fly-away.html | 182 ++ docs/skin-examples/glossy-dark.html | 99 + docs/skin-examples/hello-kitty.html | 148 ++ docs/skin-examples/matrix.html | 108 + .../old-school-computer-bezel.html | 131 ++ .../pink-internetcore-anime.html | 236 ++ docs/skin-examples/red-web-glitchcore.html | 235 ++ docs/skin-examples/round-profile-photo.html | 18 + docs/skin-examples/scenecore-emo-layout.html | 147 ++ .../spinning-round-profile-photo.html | 18 + docs/skin-examples/windows-98.html | 232 ++ docs/skin-examples/windows-vista.html | 107 + docs/skin-examples/windows-xp.html | 222 ++ docs/skins.md | 484 ++++ docs/theme-tokens.md | 492 +++++ docs/threat-model.md | 150 ++ package.json | 50 + pnpm-lock.yaml | 1939 +++++++++++++++++ pnpm-workspace.yaml | 4 + public/static/brand/apple-touch-icon.png | Bin 0 -> 2890 bytes public/static/brand/favicon.svg | 1 + public/static/brand/icon-1024.png | Bin 0 -> 35261 bytes public/static/brand/icon-192.png | Bin 0 -> 3170 bytes public/static/brand/icon-512.png | Bin 0 -> 12390 bytes public/static/brand/og-image.png | Bin 0 -> 31835 bytes public/static/brand/og-image.svg | 1 + public/static/brand/og-screenshot.webp | Bin 0 -> 63812 bytes public/static/brand/screenshot.webp | Bin 0 -> 279584 bytes public/static/brand/site.webmanifest | 32 + public/static/css/components/actions.css | 72 + public/static/css/components/avatars.css | 84 + public/static/css/components/badges.css | 23 + .../static/css/components/color-swatches.css | 43 + public/static/css/components/content.css | 107 + public/static/css/components/discussion.css | 84 + .../static/css/components/formatting-help.css | 22 + public/static/css/components/forms.css | 170 ++ public/static/css/components/groups.css | 84 + public/static/css/components/links.css | 66 + public/static/css/components/metadata.css | 12 + public/static/css/components/pagination.css | 8 + public/static/css/components/panels.css | 74 + public/static/css/components/people.css | 177 ++ public/static/css/components/posts.css | 136 ++ public/static/css/components/tables.css | 50 + public/static/css/core/base.css | 253 +++ public/static/css/core/flow.css | 10 + public/static/css/core/layout.css | 82 + public/static/css/core/tokens.css | 220 ++ public/static/css/features/profile.css | 517 +++++ .../static/css/features/skin-guardrails.css | 137 ++ public/static/css/pages/auth.css | 12 + public/static/css/pages/blogs.css | 39 + public/static/css/pages/groups.css | 16 + public/static/css/pages/home.css | 159 ++ public/static/css/pages/messages.css | 135 ++ public/static/css/pages/notifications.css | 27 + public/static/css/pages/staff.css | 270 +++ public/static/css/responsive/responsive.css | 214 ++ public/static/css/shell/shell.css | 284 +++ public/static/css/style.css | 32 + public/static/css/themes/dark.css | 17 + public/static/icons/bliish.ico | Bin 0 -> 5245 bytes public/static/media/default.mp3 | Bin 0 -> 43995 bytes public/static/media/tom.webp | Bin 0 -> 8960 bytes src/anchors.ts | 19 + src/auditLabels.ts | 62 + src/automodPolicy.ts | 26 + src/brand.ts | 10 + src/currentUser.ts | 11 + src/index.tsx | 65 + src/messages.ts | 3 + src/models.ts | 304 +++ src/navigation.ts | 33 + src/notifications.ts | 75 + src/paths.ts | 73 + src/policy.ts | 229 ++ src/project.ts | 15 + src/rateLimitLabels.ts | 25 + src/roles.ts | 66 + src/routes/account/index.tsx | 169 ++ src/routes/admin/automodRules.ts | 57 + src/routes/admin/index.tsx | 155 ++ src/routes/admin/managedUsers.ts | 53 + src/routes/admin/rateLimits.ts | 49 + src/routes/admin/siteSettings.ts | 67 + src/routes/auth/index.tsx | 206 ++ src/routes/blogs/index.tsx | 205 ++ src/routes/groups/index.tsx | 129 ++ src/routes/index.ts | 37 + src/routes/messages/index.tsx | 182 ++ src/routes/messages/recipients.ts | 42 + src/routes/moderation/index.tsx | 44 + src/routes/notifications/index.tsx | 35 + src/routes/people/index.tsx | 99 + src/routes/posts/index.tsx | 176 ++ src/routes/profile/edit/actions.ts | 161 ++ src/routes/profile/index.tsx | 184 ++ src/routes/profile/profilePageData.ts | 51 + src/routes/reports/index.tsx | 99 + src/routes/search/index.tsx | 15 + src/routes/site/index.test.ts | 60 + src/routes/site/index.tsx | 213 ++ src/routes/skins/index.tsx | 223 ++ src/routes/staff/dashboardData.ts | 29 + src/routes/staff/reportForm.ts | 15 + src/routes/system/branding.ts | 38 + src/routes/system/crawlers.ts | 18 + src/routes/system/index.ts | 13 + src/routes/system/media.ts | 70 + src/routes/system/refresh.ts | 7 + src/routes/system/theme.ts | 46 + src/scripts/generateBrandAssets.ts | 24 + src/server/access.ts | 93 + src/server/adminProtection.ts | 12 + src/server/auth/password.ts | 14 + src/server/auth/session.ts | 128 ++ src/server/branding/assets.ts | 84 + src/server/comments/actions.ts | 62 + src/server/context.ts | 11 + src/server/db/account.ts | 56 + src/server/db/automod.test.ts | 131 ++ src/server/db/automod.ts | 210 ++ src/server/db/blogs/comments.ts | 14 + src/server/db/blogs/index.test.ts | 155 ++ src/server/db/blogs/index.ts | 4 + src/server/db/blogs/mutations.ts | 43 + src/server/db/blogs/queries.ts | 114 + src/server/db/blogs/sql.ts | 76 + src/server/db/blogs/visibility.ts | 38 + src/server/db/branding.ts | 39 + src/server/db/client.ts | 12 + src/server/db/comments.ts | 170 ++ src/server/db/email.ts | 90 + src/server/db/groups.ts | 239 ++ src/server/db/indexing.ts | 104 + src/server/db/init.ts | 6 + src/server/db/like.ts | 5 + src/server/db/messages/index.ts | 2 + src/server/db/messages/mutations.ts | 33 + src/server/db/messages/queries.ts | 112 + src/server/db/messages/sql.ts | 33 + src/server/db/moderation/audit.ts | 215 ++ src/server/db/moderation/index.ts | 2 + src/server/db/moderation/reports.ts | 65 + src/server/db/moderation/subjectDelete.ts | 40 + src/server/db/moderation/subjectSources.ts | 164 ++ src/server/db/moderation/subjects.ts | 59 + src/server/db/notifications/comments.ts | 140 ++ src/server/db/notifications/index.ts | 4 + src/server/db/notifications/mutations.ts | 78 + src/server/db/notifications/preferences.ts | 42 + src/server/db/notifications/queries.ts | 38 + src/server/db/notifications/sql.ts | 47 + src/server/db/notifications/write.ts | 47 + src/server/db/personCards.ts | 8 + src/server/db/posts/commentActivity.ts | 228 ++ src/server/db/posts/comments.ts | 25 + src/server/db/posts/index.test.ts | 153 ++ src/server/db/posts/index.ts | 4 + src/server/db/posts/media.ts | 28 + src/server/db/posts/mutations.ts | 65 + src/server/db/posts/queries.ts | 237 ++ src/server/db/posts/sql.ts | 83 + src/server/db/profileVisibility.ts | 30 + src/server/db/rateLimits.test.ts | 76 + src/server/db/rateLimits.ts | 196 ++ src/server/db/relationships.test.ts | 56 + src/server/db/relationships.ts | 236 ++ src/server/db/schema.ts | 447 ++++ src/server/db/search.ts | 27 + src/server/db/settings.ts | 30 + src/server/db/siteSettings.ts | 161 ++ src/server/db/skins.test.ts | 102 + src/server/db/skins.ts | 135 ++ src/server/db/staffDashboard.ts | 106 + src/server/db/users.test.ts | 115 + src/server/db/users.ts | 292 +++ src/server/email/smtp.ts | 164 ++ src/server/env.ts | 60 + src/server/forms.ts | 15 + src/server/http.ts | 142 ++ src/server/indexing/crawlers.ts | 181 ++ src/server/indexing/middleware.ts | 28 + src/server/indexing/policy.test.ts | 180 ++ src/server/indexing/routes.ts | 96 + src/server/indexing/sitemap.ts | 66 + src/server/indexing/urls.ts | 57 + src/server/media/policy.ts | 87 + src/server/media/processing.ts | 68 + src/server/media/upload.ts | 62 + src/server/media/validation.ts | 42 + src/server/moderation/actions.test.ts | 83 + src/server/moderation/actions.ts | 88 + src/server/moderation/automodDefaults.ts | 193 ++ src/server/moderation/automodNormalize.ts | 197 ++ src/server/moderation/guards.ts | 13 + src/server/pagination.ts | 91 + src/server/rateLimit.test.ts | 60 + src/server/rateLimit.ts | 27 + src/server/render.tsx | 26 + src/server/security/crypto.ts | 15 + src/server/security/css.ts | 270 +++ src/server/security/embeds.ts | 157 ++ src/server/security/headers.ts | 32 + src/server/security/html.test.ts | 82 + src/server/security/html.ts | 3 + src/server/security/htmlToText.ts | 107 + src/server/security/skinHtml.ts | 153 ++ src/server/security/textToHtml.ts | 109 + src/server/security/urls.ts | 51 + src/settings/branding.ts | 7 + src/settings/seo.ts | 134 ++ src/settings/site.ts | 110 + src/shell/footer.tsx | 66 + src/shell/index.ts | 4 + src/shell/layout.tsx | 148 ++ src/shell/nav.tsx | 152 ++ src/shell/page.tsx | 77 + src/shell/seo.tsx | 74 + src/skins/builtin.ts | 259 +++ src/skins/colorPalette.ts | 66 + src/skins/colorPaletteEditor.tsx | 24 + src/skins/contract.ts | 41 + src/skins/docs.tsx | 19 + src/skins/rendering.tsx | 734 +++++++ src/socialLinks.test.ts | 19 + src/socialLinks.ts | 110 + src/text.ts | 5 + src/theme/colorPalette.ts | 201 ++ src/theme/themeCss.ts | 36 + src/timeZones.ts | 46 + src/types/lucide-static.d.ts | 4 + src/ui/actions.tsx | 47 + src/ui/actors.tsx | 35 + src/ui/avatars.tsx | 42 + src/ui/badges.tsx | 16 + src/ui/classes.ts | 4 + src/ui/colorSwatches.tsx | 30 + src/ui/comments.tsx | 142 ++ src/ui/discussion.tsx | 41 + src/ui/engagement.tsx | 41 + src/ui/forms.tsx | 81 + src/ui/groups.tsx | 77 + src/ui/html.ts | 4 + src/ui/icons.tsx | 109 + src/ui/links.tsx | 33 + src/ui/meta.tsx | 9 + src/ui/pagination.tsx | 17 + src/ui/panels.tsx | 46 + src/ui/people.tsx | 84 + src/ui/previews.tsx | 9 + src/ui/socialLinks.tsx | 22 + src/ui/sourceProject.tsx | 10 + src/ui/time.tsx | 85 + src/ui/types.ts | 4 + src/ui/userContent.tsx | 14 + src/values.ts | 12 + src/views/account/deleteAccount.tsx | 28 + src/views/account/favorites.tsx | 25 + src/views/account/index.tsx | 4 + src/views/account/props.tsx | 20 + src/views/account/settings.tsx | 143 ++ src/views/auth/index.ts | 4 + src/views/auth/logout.tsx | 16 + src/views/auth/reset.tsx | 55 + src/views/auth/signup.tsx | 50 + src/views/auth/verify.tsx | 13 + src/views/blogs/cards.tsx | 38 + src/views/blogs/editor.tsx | 116 + src/views/blogs/entry.tsx | 125 ++ src/views/blogs/index.ts | 3 + src/views/groups/index.tsx | 123 ++ src/views/home/dashboard.tsx | 92 + src/views/home/featuredPeople.ts | 18 + src/views/home/index.ts | 2 + src/views/home/infoPanels.tsx | 91 + src/views/home/landing.tsx | 68 + src/views/messages/compose.tsx | 33 + src/views/messages/conversations.tsx | 69 + src/views/messages/index.ts | 2 + src/views/messages/page.tsx | 44 + src/views/messages/thread.tsx | 116 + src/views/messages/types.ts | 36 + src/views/notifications/index.tsx | 136 ++ src/views/people/index.tsx | 76 + src/views/posts/cards.tsx | 146 ++ src/views/posts/comments.tsx | 29 + src/views/posts/composer.tsx | 19 + src/views/posts/index.ts | 5 + src/views/posts/pages.tsx | 32 + src/views/posts/panels.tsx | 61 + src/views/profile/actions.tsx | 178 ++ src/views/profile/details.tsx | 49 + src/views/profile/edit/form.tsx | 110 + src/views/profile/edit/index.ts | 4 + src/views/profile/edit/page.tsx | 38 + src/views/profile/edit/photoForm.tsx | 17 + src/views/profile/edit/section.tsx | 24 + src/views/profile/edit/statusPage.tsx | 27 + src/views/profile/edit/types.ts | 13 + src/views/profile/index.ts | 3 + src/views/profile/layout.tsx | 37 + src/views/profile/main.tsx | 62 + src/views/profile/page.tsx | 60 + src/views/profile/pageProps.ts | 23 + src/views/profile/sidebar.tsx | 74 + src/views/reports/index.tsx | 40 + src/views/search/index.tsx | 75 + src/views/skins/index.tsx | 220 ++ src/views/staff/audit.tsx | 87 + src/views/staff/automod.tsx | 91 + src/views/staff/blog.tsx | 22 + src/views/staff/database.tsx | 10 + src/views/staff/email.tsx | 40 + src/views/staff/favorites.tsx | 23 + src/views/staff/index.tsx | 72 + src/views/staff/nav.tsx | 36 + src/views/staff/rateLimits.tsx | 80 + src/views/staff/reports.tsx | 74 + src/views/staff/siteSettings.tsx | 107 + src/views/staff/siteSettingsTargets.ts | 15 + src/views/staff/userDetail.tsx | 44 + src/views/staff/users.tsx | 38 + tsconfig.build.json | 4 + tsconfig.json | 18 + vitest.config.ts | 8 + 352 files changed, 31387 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 PRIVACY.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 deploy/hetzner/cloud-init.yml create mode 100644 deploy/hetzner/firewall-rules.json create mode 100644 deploy/hetzner/lib.sh create mode 100644 deploy/hetzner/production.env.example create mode 100755 deploy/hetzner/provision.sh create mode 100755 deploy/hetzner/setup-github.sh create mode 100755 deploy/hetzner/update.sh create mode 100644 docs/dependency-policy.md create mode 100644 docs/deployment/hetzner-cloud-cli.md create mode 100644 docs/deployment/hetzner-cloud.md create mode 100644 docs/local-development.md create mode 100644 docs/self-hosting.md create mode 100644 docs/skin-examples/black-and-grey.html create mode 100644 docs/skin-examples/cd-case-profile-photo.html create mode 100644 docs/skin-examples/fly-away.html create mode 100644 docs/skin-examples/glossy-dark.html create mode 100644 docs/skin-examples/hello-kitty.html create mode 100644 docs/skin-examples/matrix.html create mode 100644 docs/skin-examples/old-school-computer-bezel.html create mode 100644 docs/skin-examples/pink-internetcore-anime.html create mode 100644 docs/skin-examples/red-web-glitchcore.html create mode 100644 docs/skin-examples/round-profile-photo.html create mode 100644 docs/skin-examples/scenecore-emo-layout.html create mode 100644 docs/skin-examples/spinning-round-profile-photo.html create mode 100644 docs/skin-examples/windows-98.html create mode 100644 docs/skin-examples/windows-vista.html create mode 100644 docs/skin-examples/windows-xp.html create mode 100644 docs/skins.md create mode 100644 docs/theme-tokens.md create mode 100644 docs/threat-model.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 public/static/brand/apple-touch-icon.png create mode 100644 public/static/brand/favicon.svg create mode 100644 public/static/brand/icon-1024.png create mode 100644 public/static/brand/icon-192.png create mode 100644 public/static/brand/icon-512.png create mode 100644 public/static/brand/og-image.png create mode 100644 public/static/brand/og-image.svg create mode 100644 public/static/brand/og-screenshot.webp create mode 100644 public/static/brand/screenshot.webp create mode 100644 public/static/brand/site.webmanifest create mode 100644 public/static/css/components/actions.css create mode 100644 public/static/css/components/avatars.css create mode 100644 public/static/css/components/badges.css create mode 100644 public/static/css/components/color-swatches.css create mode 100644 public/static/css/components/content.css create mode 100644 public/static/css/components/discussion.css create mode 100644 public/static/css/components/formatting-help.css create mode 100644 public/static/css/components/forms.css create mode 100644 public/static/css/components/groups.css create mode 100644 public/static/css/components/links.css create mode 100644 public/static/css/components/metadata.css create mode 100644 public/static/css/components/pagination.css create mode 100644 public/static/css/components/panels.css create mode 100644 public/static/css/components/people.css create mode 100644 public/static/css/components/posts.css create mode 100644 public/static/css/components/tables.css create mode 100644 public/static/css/core/base.css create mode 100644 public/static/css/core/flow.css create mode 100644 public/static/css/core/layout.css create mode 100644 public/static/css/core/tokens.css create mode 100644 public/static/css/features/profile.css create mode 100644 public/static/css/features/skin-guardrails.css create mode 100644 public/static/css/pages/auth.css create mode 100644 public/static/css/pages/blogs.css create mode 100644 public/static/css/pages/groups.css create mode 100644 public/static/css/pages/home.css create mode 100644 public/static/css/pages/messages.css create mode 100644 public/static/css/pages/notifications.css create mode 100644 public/static/css/pages/staff.css create mode 100644 public/static/css/responsive/responsive.css create mode 100644 public/static/css/shell/shell.css create mode 100644 public/static/css/style.css create mode 100644 public/static/css/themes/dark.css create mode 100644 public/static/icons/bliish.ico create mode 100644 public/static/media/default.mp3 create mode 100644 public/static/media/tom.webp create mode 100644 src/anchors.ts create mode 100644 src/auditLabels.ts create mode 100644 src/automodPolicy.ts create mode 100644 src/brand.ts create mode 100644 src/currentUser.ts create mode 100644 src/index.tsx create mode 100644 src/messages.ts create mode 100644 src/models.ts create mode 100644 src/navigation.ts create mode 100644 src/notifications.ts create mode 100644 src/paths.ts create mode 100644 src/policy.ts create mode 100644 src/project.ts create mode 100644 src/rateLimitLabels.ts create mode 100644 src/roles.ts create mode 100644 src/routes/account/index.tsx create mode 100644 src/routes/admin/automodRules.ts create mode 100644 src/routes/admin/index.tsx create mode 100644 src/routes/admin/managedUsers.ts create mode 100644 src/routes/admin/rateLimits.ts create mode 100644 src/routes/admin/siteSettings.ts create mode 100644 src/routes/auth/index.tsx create mode 100644 src/routes/blogs/index.tsx create mode 100644 src/routes/groups/index.tsx create mode 100644 src/routes/index.ts create mode 100644 src/routes/messages/index.tsx create mode 100644 src/routes/messages/recipients.ts create mode 100644 src/routes/moderation/index.tsx create mode 100644 src/routes/notifications/index.tsx create mode 100644 src/routes/people/index.tsx create mode 100644 src/routes/posts/index.tsx create mode 100644 src/routes/profile/edit/actions.ts create mode 100644 src/routes/profile/index.tsx create mode 100644 src/routes/profile/profilePageData.ts create mode 100644 src/routes/reports/index.tsx create mode 100644 src/routes/search/index.tsx create mode 100644 src/routes/site/index.test.ts create mode 100644 src/routes/site/index.tsx create mode 100644 src/routes/skins/index.tsx create mode 100644 src/routes/staff/dashboardData.ts create mode 100644 src/routes/staff/reportForm.ts create mode 100644 src/routes/system/branding.ts create mode 100644 src/routes/system/crawlers.ts create mode 100644 src/routes/system/index.ts create mode 100644 src/routes/system/media.ts create mode 100644 src/routes/system/refresh.ts create mode 100644 src/routes/system/theme.ts create mode 100644 src/scripts/generateBrandAssets.ts create mode 100644 src/server/access.ts create mode 100644 src/server/adminProtection.ts create mode 100644 src/server/auth/password.ts create mode 100644 src/server/auth/session.ts create mode 100644 src/server/branding/assets.ts create mode 100644 src/server/comments/actions.ts create mode 100644 src/server/context.ts create mode 100644 src/server/db/account.ts create mode 100644 src/server/db/automod.test.ts create mode 100644 src/server/db/automod.ts create mode 100644 src/server/db/blogs/comments.ts create mode 100644 src/server/db/blogs/index.test.ts create mode 100644 src/server/db/blogs/index.ts create mode 100644 src/server/db/blogs/mutations.ts create mode 100644 src/server/db/blogs/queries.ts create mode 100644 src/server/db/blogs/sql.ts create mode 100644 src/server/db/blogs/visibility.ts create mode 100644 src/server/db/branding.ts create mode 100644 src/server/db/client.ts create mode 100644 src/server/db/comments.ts create mode 100644 src/server/db/email.ts create mode 100644 src/server/db/groups.ts create mode 100644 src/server/db/indexing.ts create mode 100644 src/server/db/init.ts create mode 100644 src/server/db/like.ts create mode 100644 src/server/db/messages/index.ts create mode 100644 src/server/db/messages/mutations.ts create mode 100644 src/server/db/messages/queries.ts create mode 100644 src/server/db/messages/sql.ts create mode 100644 src/server/db/moderation/audit.ts create mode 100644 src/server/db/moderation/index.ts create mode 100644 src/server/db/moderation/reports.ts create mode 100644 src/server/db/moderation/subjectDelete.ts create mode 100644 src/server/db/moderation/subjectSources.ts create mode 100644 src/server/db/moderation/subjects.ts create mode 100644 src/server/db/notifications/comments.ts create mode 100644 src/server/db/notifications/index.ts create mode 100644 src/server/db/notifications/mutations.ts create mode 100644 src/server/db/notifications/preferences.ts create mode 100644 src/server/db/notifications/queries.ts create mode 100644 src/server/db/notifications/sql.ts create mode 100644 src/server/db/notifications/write.ts create mode 100644 src/server/db/personCards.ts create mode 100644 src/server/db/posts/commentActivity.ts create mode 100644 src/server/db/posts/comments.ts create mode 100644 src/server/db/posts/index.test.ts create mode 100644 src/server/db/posts/index.ts create mode 100644 src/server/db/posts/media.ts create mode 100644 src/server/db/posts/mutations.ts create mode 100644 src/server/db/posts/queries.ts create mode 100644 src/server/db/posts/sql.ts create mode 100644 src/server/db/profileVisibility.ts create mode 100644 src/server/db/rateLimits.test.ts create mode 100644 src/server/db/rateLimits.ts create mode 100644 src/server/db/relationships.test.ts create mode 100644 src/server/db/relationships.ts create mode 100644 src/server/db/schema.ts create mode 100644 src/server/db/search.ts create mode 100644 src/server/db/settings.ts create mode 100644 src/server/db/siteSettings.ts create mode 100644 src/server/db/skins.test.ts create mode 100644 src/server/db/skins.ts create mode 100644 src/server/db/staffDashboard.ts create mode 100644 src/server/db/users.test.ts create mode 100644 src/server/db/users.ts create mode 100644 src/server/email/smtp.ts create mode 100644 src/server/env.ts create mode 100644 src/server/forms.ts create mode 100644 src/server/http.ts create mode 100644 src/server/indexing/crawlers.ts create mode 100644 src/server/indexing/middleware.ts create mode 100644 src/server/indexing/policy.test.ts create mode 100644 src/server/indexing/routes.ts create mode 100644 src/server/indexing/sitemap.ts create mode 100644 src/server/indexing/urls.ts create mode 100644 src/server/media/policy.ts create mode 100644 src/server/media/processing.ts create mode 100644 src/server/media/upload.ts create mode 100644 src/server/media/validation.ts create mode 100644 src/server/moderation/actions.test.ts create mode 100644 src/server/moderation/actions.ts create mode 100644 src/server/moderation/automodDefaults.ts create mode 100644 src/server/moderation/automodNormalize.ts create mode 100644 src/server/moderation/guards.ts create mode 100644 src/server/pagination.ts create mode 100644 src/server/rateLimit.test.ts create mode 100644 src/server/rateLimit.ts create mode 100644 src/server/render.tsx create mode 100644 src/server/security/crypto.ts create mode 100644 src/server/security/css.ts create mode 100644 src/server/security/embeds.ts create mode 100644 src/server/security/headers.ts create mode 100644 src/server/security/html.test.ts create mode 100644 src/server/security/html.ts create mode 100644 src/server/security/htmlToText.ts create mode 100644 src/server/security/skinHtml.ts create mode 100644 src/server/security/textToHtml.ts create mode 100644 src/server/security/urls.ts create mode 100644 src/settings/branding.ts create mode 100644 src/settings/seo.ts create mode 100644 src/settings/site.ts create mode 100644 src/shell/footer.tsx create mode 100644 src/shell/index.ts create mode 100644 src/shell/layout.tsx create mode 100644 src/shell/nav.tsx create mode 100644 src/shell/page.tsx create mode 100644 src/shell/seo.tsx create mode 100644 src/skins/builtin.ts create mode 100644 src/skins/colorPalette.ts create mode 100644 src/skins/colorPaletteEditor.tsx create mode 100644 src/skins/contract.ts create mode 100644 src/skins/docs.tsx create mode 100644 src/skins/rendering.tsx create mode 100644 src/socialLinks.test.ts create mode 100644 src/socialLinks.ts create mode 100644 src/text.ts create mode 100644 src/theme/colorPalette.ts create mode 100644 src/theme/themeCss.ts create mode 100644 src/timeZones.ts create mode 100644 src/types/lucide-static.d.ts create mode 100644 src/ui/actions.tsx create mode 100644 src/ui/actors.tsx create mode 100644 src/ui/avatars.tsx create mode 100644 src/ui/badges.tsx create mode 100644 src/ui/classes.ts create mode 100644 src/ui/colorSwatches.tsx create mode 100644 src/ui/comments.tsx create mode 100644 src/ui/discussion.tsx create mode 100644 src/ui/engagement.tsx create mode 100644 src/ui/forms.tsx create mode 100644 src/ui/groups.tsx create mode 100644 src/ui/html.ts create mode 100644 src/ui/icons.tsx create mode 100644 src/ui/links.tsx create mode 100644 src/ui/meta.tsx create mode 100644 src/ui/pagination.tsx create mode 100644 src/ui/panels.tsx create mode 100644 src/ui/people.tsx create mode 100644 src/ui/previews.tsx create mode 100644 src/ui/socialLinks.tsx create mode 100644 src/ui/sourceProject.tsx create mode 100644 src/ui/time.tsx create mode 100644 src/ui/types.ts create mode 100644 src/ui/userContent.tsx create mode 100644 src/values.ts create mode 100644 src/views/account/deleteAccount.tsx create mode 100644 src/views/account/favorites.tsx create mode 100644 src/views/account/index.tsx create mode 100644 src/views/account/props.tsx create mode 100644 src/views/account/settings.tsx create mode 100644 src/views/auth/index.ts create mode 100644 src/views/auth/logout.tsx create mode 100644 src/views/auth/reset.tsx create mode 100644 src/views/auth/signup.tsx create mode 100644 src/views/auth/verify.tsx create mode 100644 src/views/blogs/cards.tsx create mode 100644 src/views/blogs/editor.tsx create mode 100644 src/views/blogs/entry.tsx create mode 100644 src/views/blogs/index.ts create mode 100644 src/views/groups/index.tsx create mode 100644 src/views/home/dashboard.tsx create mode 100644 src/views/home/featuredPeople.ts create mode 100644 src/views/home/index.ts create mode 100644 src/views/home/infoPanels.tsx create mode 100644 src/views/home/landing.tsx create mode 100644 src/views/messages/compose.tsx create mode 100644 src/views/messages/conversations.tsx create mode 100644 src/views/messages/index.ts create mode 100644 src/views/messages/page.tsx create mode 100644 src/views/messages/thread.tsx create mode 100644 src/views/messages/types.ts create mode 100644 src/views/notifications/index.tsx create mode 100644 src/views/people/index.tsx create mode 100644 src/views/posts/cards.tsx create mode 100644 src/views/posts/comments.tsx create mode 100644 src/views/posts/composer.tsx create mode 100644 src/views/posts/index.ts create mode 100644 src/views/posts/pages.tsx create mode 100644 src/views/posts/panels.tsx create mode 100644 src/views/profile/actions.tsx create mode 100644 src/views/profile/details.tsx create mode 100644 src/views/profile/edit/form.tsx create mode 100644 src/views/profile/edit/index.ts create mode 100644 src/views/profile/edit/page.tsx create mode 100644 src/views/profile/edit/photoForm.tsx create mode 100644 src/views/profile/edit/section.tsx create mode 100644 src/views/profile/edit/statusPage.tsx create mode 100644 src/views/profile/edit/types.ts create mode 100644 src/views/profile/index.ts create mode 100644 src/views/profile/layout.tsx create mode 100644 src/views/profile/main.tsx create mode 100644 src/views/profile/page.tsx create mode 100644 src/views/profile/pageProps.ts create mode 100644 src/views/profile/sidebar.tsx create mode 100644 src/views/reports/index.tsx create mode 100644 src/views/search/index.tsx create mode 100644 src/views/skins/index.tsx create mode 100644 src/views/staff/audit.tsx create mode 100644 src/views/staff/automod.tsx create mode 100644 src/views/staff/blog.tsx create mode 100644 src/views/staff/database.tsx create mode 100644 src/views/staff/email.tsx create mode 100644 src/views/staff/favorites.tsx create mode 100644 src/views/staff/index.tsx create mode 100644 src/views/staff/nav.tsx create mode 100644 src/views/staff/rateLimits.tsx create mode 100644 src/views/staff/reports.tsx create mode 100644 src/views/staff/siteSettings.tsx create mode 100644 src/views/staff/siteSettingsTargets.ts create mode 100644 src/views/staff/userDetail.tsx create mode 100644 src/views/staff/users.tsx create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f64ccc --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Local development only. For Hetzner production, copy and edit +# deploy/hetzner/production.env.example instead. +BLIISH_BASE_URL=http://localhost:3000 +BLIISH_DATABASE_PATH=./data/bliish.sqlite +BLIISH_UPLOAD_DIR=./data/uploads +BLIISH_ADMIN_USER_ID=1 +BLIISH_MEDIA_CONCURRENCY=1 +HOST=0.0.0.0 +PORT=3000 + +# Optional SMTP delivery. Messages are always stored in the local admin outbox. +BLIISH_SMTP_HOST= +BLIISH_SMTP_PORT=587 +BLIISH_SMTP_USER= +BLIISH_SMTP_PASSWORD= +BLIISH_SMTP_FROM= +BLIISH_SMTP_SECURE=0 +BLIISH_SMTP_STARTTLS=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b3c4e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + app: + name: App checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.1.3 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + deploy: + name: Deploy production + needs: app + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + environment: + name: production + url: ${{ vars.BLIISH_BASE_URL }} + concurrency: + group: production + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install SSH key + env: + BLIISH_DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.BLIISH_DEPLOY_SSH_PRIVATE_KEY }} + BLIISH_DEPLOY_KNOWN_HOSTS: ${{ secrets.BLIISH_DEPLOY_KNOWN_HOSTS }} + run: | + if [ -z "$BLIISH_DEPLOY_SSH_PRIVATE_KEY" ]; then + echo "Missing BLIISH_DEPLOY_SSH_PRIVATE_KEY" >&2 + exit 1 + fi + if [ -z "$BLIISH_DEPLOY_KNOWN_HOSTS" ]; then + echo "Missing BLIISH_DEPLOY_KNOWN_HOSTS" >&2 + exit 1 + fi + install -d -m 0700 ~/.ssh + printf '%s\n' "$BLIISH_DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 0600 ~/.ssh/id_ed25519 + printf '%s\n' "$BLIISH_DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + + - name: Deploy over SSH + env: + BLIISH_SSH_USER: ${{ vars.BLIISH_SSH_USER || 'root' }} + BLIISH_SSH_HOST: ${{ vars.BLIISH_SSH_HOST }} + BLIISH_REPOSITORY_REF: ${{ vars.BLIISH_REPOSITORY_REF || 'main' }} + run: | + if [ -z "$BLIISH_SSH_HOST" ]; then + echo "Missing BLIISH_SSH_HOST production variable" >&2 + exit 1 + fi + export BLIISH_DEPLOY_SSH_KEY="$HOME/.ssh/id_ed25519" + deploy/hetzner/update.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe5e23a --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +*.log +*.tsbuildinfo +.env +.env.* +!.env.example +deploy/hetzner/production.env +coverage/ +node_modules/ +dist/ +data/ +# Local runtime state. Keep generated databases and uploads out of source. +data/ +nohup.out +# Local SQLite databases, including WAL/SHM sidecar files. +*.db +*.db-* +*.sqlite +*.sqlite-* +*.sqlite3 +*.sqlite3-* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1ca3424 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# Architecture + +Bliish.space is a server-rendered, form-first social app. + +## Goals + +- Run on a small VPS or local machine. +- Keep runtime dependencies minimal. +- Avoid required external services. +- Keep HTML readable and auditable. +- Keep data in SQLite and uploaded files on disk. +- Support custom profiles while containing XSS and upload risk. + +## Runtime Shape + +```text +browser + -> Hono route + -> session + CSRF middleware + -> SQLite query/mutation module + -> server-rendered JSX + -> HTML response +``` + +There is no client app shell. Public pages render without required JavaScript. + +## Main Modules + +- `src/index.tsx` owns Hono setup, middleware, static file serving, error handling, database initialization, and route registration. +- `src/routes//index.tsx` owns feature route registrars. Route-specific helpers live beside the registrar, `src/routes/people/` owns browse/search/friend/block routes, `src/routes/account/` owns signed-in account pages such as settings, favorites, props, export, and deletion, `src/routes/staff/` owns shared admin/moderation route helpers, and `src/routes/system/` owns operational endpoints such as `/robots.txt`, `/theme.css`, and `/branding.css`. +- `src/views//` owns page markup for each product area. The folder `index.ts` or `index.tsx` is the public view export for that feature. +- `src/shell/index.ts` owns the app chrome public API. The shell is app-bound, so its layout, nav, footer, page frame, and split layout primitives can read or compose around site settings, roles, and rate-limit state. +- `src/automodPolicy.ts` owns automod scopes, pattern types, actions, and pattern/scan limits shared by DB code, routes, and staff UI. +- `src/currentUser.ts` owns the shared authenticated-user shape passed between auth, routes, views, and UI components. +- `src/messages.ts` owns private-message form contracts shared by message routes and views. +- `src/models.ts` owns shared product read models returned by DB modules and rendered by routes, views, and UI components. +- `src/notifications.ts` owns notification kind, subject, context, and display-label contracts shared by schema, DB creation logic, and notification views. +- `src/paths.ts` owns shared app route and media URL builders used by routes, views, and UI components. +- `src/project.ts` owns source-project metadata shown on site information pages and the footer. +- `src/policy.ts` owns shared limits, default media names, report subject policy, rate-limit defaults, and validation helpers. +- `src/roles.ts` owns named user roles and staff capability checks. +- `src/settings/` owns shared site and branding settings shapes, defaults, and settings-derived rendering helpers such as favicon SVG generation. +- `src/socialLinks.ts` owns supported profile social-link platforms plus validation and normalization. +- `src/text.ts` owns generic text-only helpers used across server code and server-rendered markup. +- `src/ui/actors.tsx`, `src/ui/avatars.tsx`, `src/ui/people.tsx`, `src/ui/comments.tsx`, `src/ui/discussion.tsx`, `src/ui/engagement.tsx`, `src/ui/forms.tsx`, `src/ui/html.ts`, and `src/ui/links.tsx` own reusable actor summaries, profile images, people cards, comments, discussion entries, props controls, CSRF inputs, trusted HTML rendering, and inline links. +- `src/ui/pagination.tsx` owns shared server-rendered pagination links. +- `src/ui/icons.tsx` owns the Lucide Static server-rendered icon wrapper. +- `src/theme/` owns color palette derivation and generated CSS for admin-controlled theme tokens. +- `src/skins/` owns the profile skin hook contract, sanitized skin rendering helpers, color-palette-to-skin helpers, and skin color-palette editor shared by profile editing, shared skin submission, profile rendering, and skin previews. +- `src/server/pagination.ts` owns opaque cursor encoding, cursor URL helpers, and shared page result shaping. +- `src/server/comments/actions.ts` owns shared comment form, automod, report, delete, and audit orchestration across posts, blogs, and skins. +- `src/server/db/settings.ts` owns the small `app_settings` key/value boundary used by branding, site settings, and rate-limit snapshots. +- `src/server/db/schema.ts` owns the SQLite schema. +- `src/server/db/*.ts` owns small single-file data modules. `src/server/db/relationships.ts` owns friendships, blocks, and favorite edges; `src/server/db/staffDashboard.ts` owns cross-table staff dashboard reads. Larger areas expose a public `index.ts`, such as `src/server/db/posts/index.ts`, `src/server/db/blogs/index.ts`, `src/server/db/messages/index.ts`, or `src/server/db/notifications/index.ts`, and keep SQL pieces beside it in the same feature folder. +- `src/server/auth/session.ts` owns cookie sessions and CSRF validation. +- `src/server/context.ts` owns Hono app bindings and request context types. +- `src/server/security/html.ts` owns the public sanitizer API; CSS, URL, embed, and HTTP header policy helpers live beside it in focused modules. +- `src/server/media/upload.ts` owns the public upload/delete API. `src/server/media/policy.ts`, `validation.ts`, and `processing.ts` keep accepted media types, signature checks, and image normalization behind that boundary. + +## Product Areas + +- Profiles, profile walls, the signed-in feed, and group posts use the post system in `src/server/db/posts/index.ts`. The feed, profile wall, and group post lists use read-time comment activity ordering in `src/server/db/posts/commentActivity.ts`; comment writes do not mutate post timestamps. +- Blogs and skins keep their own content tables and use shared comment rendering where they expose comments. +- Favorites are user-to-user relationship edges. Props are post/blog engagement records; `/props` is a viewer-filtered account page over existing post and blog prop tables, not a separate saved-item table. +- Notifications are first-party SQLite rows created by existing social form routes for wall posts, group posts, comments, props, favorites, accepted friend requests, and group additions. Notification reads and writes live under `src/server/db/notifications/`, with comment fan-out isolated in `src/server/db/notifications/comments.ts`. Private-message reads and writes live under `src/server/db/messages/`. Pending friend requests stay in their dedicated requests pages. +- Reports use `src/policy.ts` for allowed subject types and `src/server/db/moderation/subjects.ts` for subject lookup and deletion. +- Automod rules live in `src/server/db/automod.ts`: routes use `scanAutomodSubmission()` to scan sanitized UGC before persistence, reject content for `reject` matches, and carry checked text forward so normal system reports can be created for `review` matches after the subject row exists. Default critical rule packs live in `src/server/moderation/automodDefaults.ts` as readable newline-separated keyword lists and are installed once per database so admins can disable or delete them without startup restoring them. Matching uses `src/server/moderation/automodNormalize.ts` to fold common leetspeak, full-width text, homoglyphs, repeated characters, and separator bypasses before keyword or regex checks. +- Admin and moderator permissions use named roles only: `user`, `moderator`, and `admin`. Automod management is admin-only; moderators act through the report queue and role hierarchy checks. + +## Database + +SQLite runs in WAL mode. The app uses explicit `better-sqlite3` prepared statements instead of an ORM, keeping schema and query behavior in source. + +The schema source of truth is `src/server/db/schema.ts`. Startup creates tables and indexes for a fresh database, and `pnpm db:init` can be used for explicit setup. Schema changes should update the schema source, account export, moderation subjects, automod policy, and docs together. + +Chronological list pages use keyset pagination through opaque `before` cursors. Keep SQL ownership in the feature DB module, fetch `limit + 1` rows, and use `src/server/pagination.ts` to shape the page and produce the next cursor. Prefer this pattern over offset pagination for mutable feeds, walls, groups, messages, notifications, and moderation queues. + +## Auth + +Passwords are hashed with Argon2id. Sessions use random opaque tokens. Only token hashes are stored in SQLite. + +Staff privileges are capability-based. Route and visibility decisions use named roles (`user`, `moderator`, `admin`) through `src/roles.ts`. + +## HTML Safety + +Profile customization is the highest-risk product feature. User text and skin HTML must pass through sanitizer functions before storage. Raw untrusted input must not be the render source. Skins allow sanitized passive CSS and local or HTTPS resource URLs. Active code, unscoped global selectors, unsafe URL schemes, arbitrary attribute selectors, and CSS patterns that can read DOM attributes are stripped. Profile pages expose stable `data-skin-page`, `data-skin-root`, and `data-skin-part` hooks; the page hook accepts only `--skin-*` variables, while styling selectors must be rooted at the profile root or a skin part. + +Remote HTTPS images, CSS backgrounds, and Google Fonts imports are allowed in skin HTML by `src/server/security/html.ts` and by the CSP in `src/server/security/headers.ts`. Those browser-side requests are part of custom profile rendering, not required app infrastructure. + +## Request Safety + +Form-based mutating routes use CSRF tokens and server-side validation. Email verification and password resets use one-time token hashes stored in SQLite; the local email outbox records the messages that carry those links. The app rejects oversized request bodies before parsing form data. Security headers are applied at the Hono layer. + +Mutating form routes can opt into named action rate limits through the shared form helper. Source-controlled defaults live in policy, and admins can override them from the local admin panel. Rate-limit counters are kept in SQLite as short-lived action names and hashed account or submitted form-subject keys, preserving the app's local-only deployment shape. + +## Uploads + +Uploads are stored on disk under `BLIISH_UPLOAD_DIR`, which defaults to `data/uploads` in local development. Validation checks size, allowed extension/MIME, and file signature. Filenames are random UUIDs. + +Profile images, post images, and theme songs live in separate upload buckets. Deletion paths that remove posts, groups, or accounts are responsible for removing referenced post-image files as well as the database rows. + +## Static Assets + +Static app assets, including the default theme song file and dark theme CSS, are served from `public/static`. Uploaded profile images, post images, and theme songs are served from the configured upload directory through `/media/pfp/*`, `/media/post-images/*`, and `/media/theme-songs/*`; the upload root itself is not mounted. + +The default layout loads `style.css`, a small cookie-scoped `/theme.css` response for the optional dark theme, and `/branding.css` for saved admin theme colors. `style.css` is the CSS manifest: `core/`, `shell/`, `components/`, `features/`, `pages/`, and `responsive/` CSS are imported in explicit cascade order. Add only selectors and static scripts the app actually renders. + +## Indexing and Crawlers + +Search indexing is default-deny and server-side. `src/server/indexing/` owns route indexability, crawler blocking, robots.txt text, and sitemap generation, using row-level public predicates from `src/server/db/indexing.ts` for mixed routes such as profiles, blogs, and skins. Public pages receive a canonical `Link` header. Everything else receives an `X-Robots-Tag` noindex header, and crawler-like user agents are blocked from non-indexable pages. + +`/robots.txt` and `/sitemap.xml` are generated by `src/routes/system/crawlers.ts` from the same policy. The sitemap lists only public canonical URLs and is capped to the sitemap protocol's 50,000 URL limit. New public content surfaces should add one DB predicate and one resolver branch in `src/server/indexing/routes.ts` instead of duplicating visibility logic in views. + +## Non-Goals + +- No mandatory OAuth. +- No mandatory object storage. +- No mandatory hosted database. +- No mandatory queue. +- No mandatory CDN. +- No analytics by default. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c12c07 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +Bliish.space stays small, readable, self-hosted, and explicit about its tradeoffs. + +## Principles + +- Keep core workflows server-rendered and form-first. +- Do not add a framework, service, queue, worker, cache, or ORM unless it removes more code and risk than it adds. +- Prefer prepared SQL, plain HTML, and plain CSS over hidden abstractions. +- Check `docs/dependency-policy.md` before adding a dependency. +- Keep submitted code cohesive enough to review in one pass. + +## Development + +```bash +pnpm install +pnpm db:init +pnpm dev +``` + +Useful checks: + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +## Code Standards + +- Keep files cohesive and small enough to review. +- Use TypeScript types for auth, permissions, database rows, and sanitizer boundaries. +- Use `better-sqlite3` prepared statements for database access. +- Keep the database schema source of truth in `src/server/db/schema.ts`; schema work starts from a fresh local data directory. +- Sanitize user HTML before storage and only render sanitized HTML. +- Keep mutations protected by CSRF. +- Validate IDs and ownership before mutating data. +- Keep route handlers thin: routes validate and authorize; database modules own SQL; views own markup. +- Reuse shared UI such as `CommentList`, `CommentPanel`, `Panel`, `ProfileImage`, and `trustedHtml` instead of duplicating page-local variants. +- When deleting rows that reference uploaded files, delete the filesystem objects in the same route or orchestration path. +- New reportable content must update `reportSubjectTypes`, `src/server/db/moderation/subjects.ts`, and the report links that expose it. +- Do not expose raw server errors to users. +- Do not commit generated build output, local SQLite files, uploaded media, dependency directories, or private notes. +- Keep documentation tied to code paths. If a doc describes behavior, verify the route, database helper, sanitizer, deployment script, or policy constant that implements it. + +## AI-Assisted Contributions + +AI coding tools may be used to assist with contributions. Contributors remain responsible for reviewing, editing, testing, licensing, and security of all submitted code. + +Disclose substantial AI assistance in the pull request description or commit message without making it prominent project branding. A concise note is enough: + +```text +AI assistance: AI tools were used to help draft or refactor parts of this change. I reviewed, edited, and tested the final code. +``` + +## Pull Request Checklist + +- Describe the behavior change and the affected routes or modules. +- Disclose substantial AI assistance, if used. +- Run `pnpm typecheck`, `pnpm test`, and `pnpm build`. +- Include tests for auth, permissions, sanitization, uploads, or destructive actions when touched. +- Include screenshots for visible UI changes. +- Update docs when setup, schema, deployment, security behavior, or project policy changes. +- Confirm no new external network dependency was introduced. +- Confirm attribution is captured in `NOTICE` for any third-party material. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2821ca3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +Bliish.space Notice +=================== + +Bliish.space is an independent open-source project for self-hosted, profile-first social networking. + +Bliish.space is made by Bliish and Bliish.space contributors. + +License +------- + +Bliish.space source code is licensed under GPL-3.0-only. See LICENSE. + +Project Credits +--------------- + +- Bliish, for Bliish.space. https://bliish.com +- Bliish.space contributors. + +Bundled Third-Party Material +---------------------------- + +- Lucide Static is used through the `lucide-static` package for interface icons and admin-selected site icons. + License: ISC. + Source: https://github.com/lucide-icons/lucide + +Bundled Project Assets +---------------------- + +- `public/static/media/default.mp3` is a silent fallback theme-song file created for this project. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..916e681 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,72 @@ +# Privacy + +Bliish.space is self-hosted. The default app does not require external APIs, external analytics, ad networks, or tracking pixels. + +## Data Stored By The App + +The local SQLite database stores: + +- account email; +- username; +- account role; +- password hash; +- session hashes and CSRF tokens; +- short-lived rate-limit counters with action names and hashed account, email, or reset-token keys; +- verification and password reset token hashes; +- profile fields; +- friend relationships; +- favorites; +- user blocks; +- wall posts, group posts, post props, and post comments; +- blogs, blog props, and blog comments; +- groups and group memberships; +- private messages; +- notification records; +- shared skins and skin comments; +- reports, including system-created automod reports; +- automod rule names, patterns, scopes, and actions; +- queued local email outbox messages; +- moderation audit log entries; +- timestamps for account and session activity. + +Uploaded profile pictures, post images, and theme songs are stored on the local filesystem under the configured upload directory. + +Favorites and props are user action records. The app uses them to render the signed-in user's Favs and Props pages; the Props page is a filtered view of posts and blog entries the user has propped. + +## Cookies + +Bliish.space uses: + +- an HTTP-only session cookie for logged-in users; +- a CSRF token cookie for form protection; +- an HTTP-only color theme cookie. + +The default app does not use third-party cookies. + +## Locale And Time Display + +Signed-in users can save a timezone so the app can display timestamps correctly for their account. The default app does not store locale or date-format preferences. + +## Network Requests + +The server does not require third-party services during normal user workflows. Password reset and verification messages are written to the local SQLite email outbox. If SMTP is configured, the app also attempts to deliver those messages through the operator's SMTP server. + +Skin HTML can include sanitized HTTPS image URLs, CSS background URLs, Google Fonts imports, and sandboxed embeds from whitelisted player hosts such as YouTube, SoundCloud, Vimeo, Spotify, Bandcamp, TikTok, and Dailymotion. Visiting a customized profile can therefore make the visitor's browser request third-party resources chosen by that profile author. Ordinary user text fields do not allow image tags or embeds. + +## Export And Delete + +Users can export account data from `/account/export.json`. + +Users can delete their account from account settings. Deletion removes database rows linked by cascading foreign keys and removes uploaded profile, post image, and theme song files referenced by deleted records. Moderation reports and audit entries may remain with deleted user references cleared where the schema uses `ON DELETE SET NULL`. Operators remain responsible for backups, web server logs, and retained system logs outside the app. + +## Operator Responsibilities + +Instance operators should publish their own privacy notice covering: + +- legal jurisdiction; +- log retention; +- backup retention; +- moderation policy; +- administrator access; +- SMTP provider, if configured; +- reverse proxy and CDN behavior, if any. diff --git a/README.md b/README.md new file mode 100644 index 0000000..643c283 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +
+ bliish.space icon +

bliish.space

+

a space for anyone

+

+ License: GPL-3.0-only + Node.js 24+ + TypeScript + Hono +

+
+ +![Bliish.space screenshot](./public/static/brand/screenshot.webp) + +[Bliish.space](https://bliish.space) is an **ultra-fast**, **lightweight**, **open-source social platform** with **customizable profiles**, **no ads**, **no tracking**, and **simple, affordable self-hosting**. It includes profiles, wall posts, friends, favorites, props, blogs, comments, groups, notifications, **private messages**, **automoderation**, and **user-friendly admin tools**. + +> **Early release:** This project is under **active development**. Expect changes to **setup**, **deployment**, and **documentation**. + +## Highlights + +🔓 **Open source:** **GPL-3.0-only** code that people can inspect, change, run, and share. + +🚫 **No tracking or ads:** No built-in analytics pixels, ad networks, or **personalized feed algorithm**. + +🎨 **Custom profiles:** Profiles can include a wall, friends, groups, blogs, messages, **theme songs**, and **playful skins**. + +⚡ **Small and lightning fast:** **One Node process**, **SQLite**, local uploads, server-rendered pages, and **no required third-party runtime services**. + +🛠️ **Easy to manage:** Admins can change **branding**, home copy, moderation, automod, rate limits, users, and email **without touching code**. + +💸 **Tiny hosting bill:** A **Hetzner CX23 VPS** costs about **$4.99/month** for a **50k-member community**, or run it on a server you already own with no new hosting bill. + +🌐 **Light by default:** **Server-rendered pages** stay quick on low-powered devices and low-data connections, with **no required client-side JavaScript**. + +🛡️ **Safer communities:** **Automod**, reports, user blocks, bans, rate limits, and moderator accounts give communities practical moderation tools. + +## Quick Start + +```bash +corepack enable +pnpm install +pnpm db:init +pnpm dev +``` + +Open `http://localhost:3000`. + +With the default environment, the first signed-up account becomes the admin. Sign in with that account and open `/admin` to configure the instance. + +`pnpm db:init` initializes a fresh empty database. The app also initializes the schema on startup. + +## Checks + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +## Default Brand Assets + +Default Bliish.space favicon, social preview, app icon, and manifest files are generated from the same settings-derived asset helpers used by the runtime branding routes. + +```bash +pnpm assets:brand +pnpm assets:brand:clean +``` + +## Self-Hosting + +The recommended production path is one small Hetzner Cloud VPS in Europe, managed with the [CLI deployment guide](docs/deployment/hetzner-cloud-cli.md). The default `cx23` target is intentionally modest: with controlled uploads and normal social activity, it is a practical starting point for roughly 10k-50k registered accounts and 1k-5k daily active users before operators should profile, tune, or resize. Official maintainers deploy `bliish.space` from this repository. Independent operators should use a public fork or copy if they want GitHub auto-deploys for their own instance; manual VPS updates can also pull directly from upstream. + +## Project Layout + +```text +src/ + routes/ HTTP routes and form handlers; nested helpers live beside their feature + views/ server-rendered page markup grouped by product area + shell/ app chrome: default layout, navigation, footer, global banners, and page layout primitives + ui/ shared forms, icons, panels, avatars, actor summaries, links, people, comments, discussion, and engagement components + server/ auth, database, relationship data, indexing, uploads, rendering, moderation, and security + scripts/ asset-generation scripts + automodPolicy.ts shared automod scope, action, pattern, and limit policy + brand.ts shared default brand icon SVG source + currentUser.ts shared authenticated-user shape + messages.ts shared private-message form contracts + models.ts shared product read models + notifications.ts shared notification type and label contracts + paths.ts shared app route and media URL builders + policy.ts shared limits, defaults, validation, and rate-limit policy + roles.ts shared roles and staff permission checks + socialLinks.ts shared profile social-link validation and platform metadata + text.ts shared text formatting helpers + values.ts shared unknown-value parsing helpers + theme/ color palette derivation and generated theme CSS helpers + skins/ profile skin contracts, color palette helpers, and skin color-palette editor +public/ static CSS, icons, and bundled media assets; CSS is grouped by cascade layer +data/ local SQLite database and uploads, created at runtime and ignored by git +docs/ operator and security notes +deploy/ provider-specific deployment scripts +``` + +## Contributor Orientation + +- Routes stay in `src/routes`; they handle auth, form parsing, validation, and redirects. +- Views stay in feature folders under `src/views`; they render page markup and reuse focused UI modules from `src/ui`. +- SQLite access stays in `src/server/db`; public feature modules can delegate to nested implementation files. +- `src/server/db/schema.ts` is the schema source of truth. Keep account export, moderation, and automod code in sync with schema changes. +- User HTML must be sanitized before storage and rendered through the shared `trustedHtml` boundary. +- Runtime output stays out of source: `data`, `dist`, and `node_modules` are ignored local state. + +## Runtime Choices + +- Hono for the HTTP server. +- TypeScript with server-rendered JSX. +- SQLite through `better-sqlite3`. +- Argon2id password hashing. +- Plain CSS. +- Local filesystem uploads. +- Vitest for server-side tests. + +## License + +Bliish.space is licensed under [GPL-3.0-only](LICENSE). Official license text: [gnu.org/licenses/gpl-3.0.html](https://www.gnu.org/licenses/gpl-3.0.html). + +## Documentation + +- [Architecture](ARCHITECTURE.md) +- [Contributing](CONTRIBUTING.md) +- [Security Policy](SECURITY.md) +- [Privacy](PRIVACY.md) +- [Local Development](docs/local-development.md) +- [Profile Skins](docs/skins.md) +- [Theme Tokens](docs/theme-tokens.md) +- [Self-hosting](docs/self-hosting.md) +- [Hetzner Cloud CLI Deployment](docs/deployment/hetzner-cloud-cli.md) +- [Hetzner Cloud Manual Deployment](docs/deployment/hetzner-cloud.md) +- [Threat Model](docs/threat-model.md) +- [Dependency Policy](docs/dependency-policy.md) +- [License](LICENSE) +- [Notice and Attribution](NOTICE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ff00b96 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +Bliish.space deployments are operator-managed instances. + +## Reporting Vulnerabilities + +Report sensitive issues through the repository's private vulnerability reporting channel if one is configured. If not, contact the maintainer privately before publishing details. + +For non-sensitive issues, open a public issue with: + +- affected version or commit; +- affected route or feature; +- expected behavior; +- actual behavior; +- reproduction steps; +- impact assessment. + +## Supported Versions + +Security fixes target the current main branch unless a release branch states otherwise. + +## Security Boundaries + +Bliish.space currently treats these areas as security-critical: + +- password hashing; +- cookie sessions; +- CSRF checks; +- profile HTML and skin sanitization; +- upload validation and normalization; +- oversized request rejection; +- form action rate limiting; +- security headers; +- account export and deletion; +- role-based admin and moderator actions; +- report moderation; +- post, comment, and upload deletion paths; +- private profile visibility. + +## Non-Goals + +Bliish.space does not provide: + +- hosted abuse monitoring; +- centralized moderation; +- managed backups; +- managed email reputation; +- DDoS protection; +- security guarantees for modified custom skins or reverse proxy configs. + +Instance operators are responsible for host hardening, TLS, backups, logs, and abuse response. diff --git a/deploy/hetzner/cloud-init.yml b/deploy/hetzner/cloud-init.yml new file mode 100644 index 0000000..2acc273 --- /dev/null +++ b/deploy/hetzner/cloud-init.yml @@ -0,0 +1,128 @@ +#cloud-config +package_update: true +package_upgrade: false + +write_files: + - path: /usr/local/sbin/install-bliishspace + owner: root:root + permissions: "0755" + content: | + #!/usr/bin/env bash + set -euo pipefail + + export DEBIAN_FRONTEND=noninteractive + + apt-get update + apt-get install -y ca-certificates curl git sqlite3 build-essential python3 caddy gnupg + + if ! command -v node >/dev/null 2>&1 || ! node --version | grep -q '^v__BLIISH_NODE_MAJOR__\.'; then + install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg + chmod 0644 /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node___BLIISH_NODE_MAJOR__.x nodistro main" >/etc/apt/sources.list.d/nodesource.list + apt-get update + apt-get install -y nodejs + fi + + if ! command -v corepack >/dev/null 2>&1; then + npm install -g corepack + fi + + corepack enable + + if ! id -u bliish >/dev/null 2>&1; then + useradd --system --create-home --home-dir /var/lib/bliishspace --shell /usr/sbin/nologin bliish + fi + + install -d -o bliish -g bliish /var/lib/bliishspace + install -d -o bliish -g bliish /var/lib/bliishspace/uploads + install -d -o root -g root /opt + + if [ ! -d /opt/bliishspace/.git ]; then + rm -rf /opt/bliishspace + git clone "__BLIISH_REPOSITORY_URL__" /opt/bliishspace + fi + + git -C /opt/bliishspace fetch --all --tags + git -C /opt/bliishspace checkout "__BLIISH_REPOSITORY_REF__" + chown -R root:root /opt/bliishspace + + cat >/etc/bliishspace.env <<'ENV' + BLIISH_BASE_URL=__BLIISH_BASE_URL__ + BLIISH_DATABASE_PATH=/var/lib/bliishspace/bliish.sqlite + BLIISH_UPLOAD_DIR=/var/lib/bliishspace/uploads + BLIISH_ADMIN_USER_ID=__BLIISH_ADMIN_USER_ID__ + BLIISH_MEDIA_CONCURRENCY=__BLIISH_MEDIA_CONCURRENCY__ + HOST=127.0.0.1 + PORT=3000 + ENV + + chown root:bliish /etc/bliishspace.env + chmod 0640 /etc/bliishspace.env + + cd /opt/bliishspace + PNPM_PACKAGE_MANAGER="$(node -p "require('./package.json').packageManager || 'pnpm@11.1.3'")" + corepack prepare "$PNPM_PACKAGE_MANAGER" --activate + pnpm install --frozen-lockfile + pnpm build + + runuser -u bliish -- bash -lc 'set -a; . /etc/bliishspace.env; set +a; cd /opt/bliishspace; pnpm db:init' + + cat >/etc/systemd/system/bliishspace.service <<'SERVICE' + [Unit] + Description=Bliish.space + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + User=bliish + Group=bliish + WorkingDirectory=/opt/bliishspace + EnvironmentFile=/etc/bliishspace.env + ExecStart=/usr/bin/node /opt/bliishspace/dist/index.js + Restart=on-failure + RestartSec=5 + NoNewPrivileges=true + PrivateTmp=true + PrivateDevices=true + ProtectHome=true + ProtectSystem=strict + ProtectKernelTunables=true + ProtectKernelModules=true + ProtectControlGroups=true + RestrictSUIDSGID=true + LockPersonality=true + SystemCallArchitectures=native + ReadWritePaths=/var/lib/bliishspace + + [Install] + WantedBy=multi-user.target + SERVICE + + systemctl daemon-reload + systemctl enable --now bliishspace + + cat >/etc/caddy/Caddyfile <<'CADDY' + { + email hi@bliish.com + } + + __BLIISH_DOMAIN__ { + encode zstd gzip + reverse_proxy 127.0.0.1:3000 + } + + www.__BLIISH_DOMAIN__ { + redir https://__BLIISH_DOMAIN__{uri} permanent + } + CADDY + + caddy validate --config /etc/caddy/Caddyfile + systemctl enable --now caddy + systemctl reload caddy + +runcmd: + - [bash, /usr/local/sbin/install-bliishspace] + +final_message: "Bliish.space cloud-init finished. Check systemctl status bliishspace and journalctl -u bliishspace." diff --git a/deploy/hetzner/firewall-rules.json b/deploy/hetzner/firewall-rules.json new file mode 100644 index 0000000..7e00b01 --- /dev/null +++ b/deploy/hetzner/firewall-rules.json @@ -0,0 +1,23 @@ +[ + { + "direction": "in", + "protocol": "tcp", + "port": "22", + "source_ips": ["0.0.0.0/0", "::/0"], + "description": "SSH" + }, + { + "direction": "in", + "protocol": "tcp", + "port": "80", + "source_ips": ["0.0.0.0/0", "::/0"], + "description": "HTTP" + }, + { + "direction": "in", + "protocol": "tcp", + "port": "443", + "source_ips": ["0.0.0.0/0", "::/0"], + "description": "HTTPS" + } +] diff --git a/deploy/hetzner/lib.sh b/deploy/hetzner/lib.sh new file mode 100644 index 0000000..6739128 --- /dev/null +++ b/deploy/hetzner/lib.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$DEPLOY_DIR/../.." && pwd)" +ENV_FILE="${BLIISH_PRODUCTION_ENV_FILE:-$DEPLOY_DIR/production.env}" + +load_optional_production_env() { + if [ -r "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" + fi +} + +load_required_production_env() { + if [ -r "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" + return + fi + + echo "Production env file not found: $ENV_FILE" >&2 + echo "Copy deploy/hetzner/production.env.example to deploy/hetzner/production.env first." >&2 + exit 1 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +escape_sed_replacement() { + printf "%s" "$1" | sed -e 's/[\/&|\\]/\\&/g' +} + +require_no_control_chars() { + case "$2" in + *$'\n'*|*$'\r'*|*$'\t'*) + echo "$1 must not contain control characters" >&2 + exit 1 + ;; + esac +} + +require_simple_token() { + local name="$1" + local value="$2" + local pattern="$3" + + if [[ ! "$value" =~ $pattern ]]; then + echo "$name has an unsupported value: $value" >&2 + exit 1 + fi +} + +require_repository_ref() { + require_no_control_chars "$1" "$2" + case "$2" in + *[[:space:]\\]*) + echo "$1 must not contain whitespace or backslashes" >&2 + exit 1 + ;; + esac + require_simple_token "$1" "$2" '^[A-Za-z0-9][A-Za-z0-9._/-]*$' +} + +normalize_domain() { + local domain="$1" + domain="${domain#http://}" + domain="${domain#https://}" + domain="${domain%%/*}" + printf "%s" "$domain" +} diff --git a/deploy/hetzner/production.env.example b/deploy/hetzner/production.env.example new file mode 100644 index 0000000..5729da8 --- /dev/null +++ b/deploy/hetzner/production.env.example @@ -0,0 +1,43 @@ +# Copy this file to deploy/hetzner/production.env and edit it. +# production.env is ignored by git. +# +# Bliish project maintainers can keep the defaults to deploy bliish.space from +# bliish-com/bliishspace. +# +# Independent self-hosters who want GitHub auto-deploys should fork or copy the +# repository publicly, then change at least: +# - BLIISH_DOMAIN +# - BLIISH_BASE_URL +# - BLIISH_REPOSITORY_URL +# - BLIISH_GITHUB_REPOSITORY + +# Public site identity. +BLIISH_DOMAIN=bliish.space +BLIISH_BASE_URL=https://bliish.space +BLIISH_SERVER_NAME=bliishspace-1 + +# Hetzner Cloud target. nbg1 is Nuremberg, Germany. +BLIISH_HCLOUD_LOCATION=nbg1 +BLIISH_HCLOUD_SERVER_TYPE=cx23 +BLIISH_HCLOUD_IMAGE=ubuntu-24.04 +BLIISH_HCLOUD_ENABLE_IPV4=true +BLIISH_HCLOUD_ENABLE_BACKUPS=false +BLIISH_HCLOUD_ENABLE_DELETE_PROTECTION=true + +# SSH key used for provisioning and GitHub deploys. provision.sh creates it if +# neither the private nor public key exists. +BLIISH_DEPLOY_SSH_KEY=$HOME/.ssh/bliishspace-deploy +BLIISH_HCLOUD_SSH_KEY_NAME=bliishspace-admin +BLIISH_HCLOUD_SSH_PUBLIC_KEY=$HOME/.ssh/bliishspace-deploy.pub +BLIISH_SSH_USER=root + +# Application source. +BLIISH_REPOSITORY_URL=https://github.com/bliish-com/bliishspace.git +BLIISH_REPOSITORY_REF=main +BLIISH_ADMIN_USER_ID=1 +BLIISH_MEDIA_CONCURRENCY=1 +BLIISH_NODE_MAJOR=24 + +# GitHub repository that receives the production deploy secrets and variables. +BLIISH_GITHUB_REPOSITORY=bliish-com/bliishspace +BLIISH_GITHUB_ENVIRONMENT=production diff --git a/deploy/hetzner/provision.sh b/deploy/hetzner/provision.sh new file mode 100755 index 0000000..7cb1e5b --- /dev/null +++ b/deploy/hetzner/provision.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=deploy/hetzner/lib.sh +. "$DEPLOY_DIR/lib.sh" +load_optional_production_env + +render_cloud_init() { + sed \ + -e "s|__BLIISH_DOMAIN__|$(escape_sed_replacement "$BLIISH_DOMAIN")|g" \ + -e "s|__BLIISH_BASE_URL__|$(escape_sed_replacement "$BLIISH_BASE_URL")|g" \ + -e "s|__BLIISH_REPOSITORY_URL__|$(escape_sed_replacement "$BLIISH_REPOSITORY_URL")|g" \ + -e "s|__BLIISH_REPOSITORY_REF__|$(escape_sed_replacement "$BLIISH_REPOSITORY_REF")|g" \ + -e "s|__BLIISH_ADMIN_USER_ID__|$(escape_sed_replacement "$BLIISH_ADMIN_USER_ID")|g" \ + -e "s|__BLIISH_MEDIA_CONCURRENCY__|$(escape_sed_replacement "$BLIISH_MEDIA_CONCURRENCY")|g" \ + -e "s|__BLIISH_NODE_MAJOR__|$(escape_sed_replacement "$BLIISH_NODE_MAJOR")|g" \ + "$DEPLOY_DIR/cloud-init.yml" > "$USER_DATA_FILE" +} + +# Check local tooling before touching Hetzner resources. +require_command hcloud +require_command sed +require_command mktemp + +# Operator-overridable defaults. The deployment guide documents each one. +BLIISH_SERVER_NAME="${BLIISH_SERVER_NAME:-bliishspace-1}" +BLIISH_HCLOUD_SERVER_TYPE="${BLIISH_HCLOUD_SERVER_TYPE:-cx23}" +BLIISH_HCLOUD_IMAGE="${BLIISH_HCLOUD_IMAGE:-ubuntu-24.04}" +BLIISH_HCLOUD_LOCATION="${BLIISH_HCLOUD_LOCATION:-nbg1}" +BLIISH_HCLOUD_ENABLE_IPV4="${BLIISH_HCLOUD_ENABLE_IPV4:-true}" +BLIISH_DEPLOY_SSH_KEY="${BLIISH_DEPLOY_SSH_KEY:-$HOME/.ssh/bliishspace-deploy}" +BLIISH_HCLOUD_SSH_KEY_NAME="${BLIISH_HCLOUD_SSH_KEY_NAME:-bliishspace-admin}" +BLIISH_HCLOUD_SSH_PUBLIC_KEY="${BLIISH_HCLOUD_SSH_PUBLIC_KEY:-$BLIISH_DEPLOY_SSH_KEY.pub}" +BLIISH_HCLOUD_FIREWALL_NAME="${BLIISH_HCLOUD_FIREWALL_NAME:-bliishspace-web}" +BLIISH_HCLOUD_FIREWALL_RULES="${BLIISH_HCLOUD_FIREWALL_RULES:-$DEPLOY_DIR/firewall-rules.json}" +BLIISH_REPOSITORY_URL="${BLIISH_REPOSITORY_URL:-https://github.com/bliish-com/bliishspace.git}" +BLIISH_REPOSITORY_REF="${BLIISH_REPOSITORY_REF:-main}" +BLIISH_ADMIN_USER_ID="${BLIISH_ADMIN_USER_ID:-1}" +BLIISH_MEDIA_CONCURRENCY="${BLIISH_MEDIA_CONCURRENCY:-1}" +BLIISH_NODE_MAJOR="${BLIISH_NODE_MAJOR:-24}" +BLIISH_HCLOUD_ENABLE_BACKUPS="${BLIISH_HCLOUD_ENABLE_BACKUPS:-false}" +BLIISH_HCLOUD_ENABLE_DELETE_PROTECTION="${BLIISH_HCLOUD_ENABLE_DELETE_PROTECTION:-true}" + +# Accept either BLIISH_DOMAIN or a full BLIISH_BASE_URL, then normalize them for +# cloud-init and Caddy. +if [ -z "${BLIISH_DOMAIN:-}" ] && [ -n "${BLIISH_BASE_URL:-}" ]; then + BLIISH_DOMAIN="$(normalize_domain "$BLIISH_BASE_URL")" +fi + +if [ -z "${BLIISH_DOMAIN:-}" ]; then + echo "Set BLIISH_DOMAIN, for example: BLIISH_DOMAIN=bliish.space deploy/hetzner/provision.sh" >&2 + exit 1 +fi + +BLIISH_DOMAIN="$(normalize_domain "$BLIISH_DOMAIN")" +BLIISH_BASE_URL="${BLIISH_BASE_URL:-https://$BLIISH_DOMAIN}" +USER_DATA_FILE="${BLIISH_HCLOUD_USER_DATA_FILE:-$(mktemp "${TMPDIR:-/tmp}/bliishspace-cloud-init.XXXXXX")}" +EXPECTED_BLIISH_BASE_URL="https://$BLIISH_DOMAIN" + +# These values are interpolated into cloud-init, so reject control characters +# and shell-sensitive tokens before rendering the template. +require_no_control_chars BLIISH_DOMAIN "$BLIISH_DOMAIN" +require_no_control_chars BLIISH_BASE_URL "$BLIISH_BASE_URL" +require_no_control_chars BLIISH_HCLOUD_LOCATION "$BLIISH_HCLOUD_LOCATION" +require_no_control_chars BLIISH_HCLOUD_SERVER_TYPE "$BLIISH_HCLOUD_SERVER_TYPE" +require_no_control_chars BLIISH_HCLOUD_IMAGE "$BLIISH_HCLOUD_IMAGE" +require_no_control_chars BLIISH_REPOSITORY_URL "$BLIISH_REPOSITORY_URL" +require_no_control_chars BLIISH_REPOSITORY_REF "$BLIISH_REPOSITORY_REF" +require_no_control_chars BLIISH_ADMIN_USER_ID "$BLIISH_ADMIN_USER_ID" +require_no_control_chars BLIISH_MEDIA_CONCURRENCY "$BLIISH_MEDIA_CONCURRENCY" +require_no_control_chars BLIISH_NODE_MAJOR "$BLIISH_NODE_MAJOR" +require_simple_token BLIISH_DOMAIN "$BLIISH_DOMAIN" '^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$' +require_simple_token BLIISH_HCLOUD_LOCATION "$BLIISH_HCLOUD_LOCATION" '^[a-z0-9][a-z0-9-]*$' +require_simple_token BLIISH_HCLOUD_SERVER_TYPE "$BLIISH_HCLOUD_SERVER_TYPE" '^[A-Za-z0-9][A-Za-z0-9._-]*$' +require_simple_token BLIISH_HCLOUD_IMAGE "$BLIISH_HCLOUD_IMAGE" '^[A-Za-z0-9][A-Za-z0-9._-]*$' +require_simple_token BLIISH_ADMIN_USER_ID "$BLIISH_ADMIN_USER_ID" '^[0-9]+$' +require_simple_token BLIISH_MEDIA_CONCURRENCY "$BLIISH_MEDIA_CONCURRENCY" '^[1-8]$' +require_simple_token BLIISH_NODE_MAJOR "$BLIISH_NODE_MAJOR" '^[0-9]+$' + +case "$BLIISH_BASE_URL" in + https://*) ;; + *) + echo "BLIISH_BASE_URL must use https:// for this production deployment path" >&2 + exit 1 + ;; +esac + +if [ "$BLIISH_BASE_URL" != "$EXPECTED_BLIISH_BASE_URL" ]; then + echo "BLIISH_BASE_URL must exactly match $EXPECTED_BLIISH_BASE_URL" >&2 + exit 1 +fi + +case "$BLIISH_REPOSITORY_URL" in + -*) + echo "BLIISH_REPOSITORY_URL must not start with '-'" >&2 + exit 1 + ;; + *[[:space:]\\]*) + echo "BLIISH_REPOSITORY_URL must not contain whitespace or backslashes" >&2 + exit 1 + ;; +esac + +require_simple_token BLIISH_REPOSITORY_URL "$BLIISH_REPOSITORY_URL" '^[A-Za-z0-9][A-Za-z0-9.+:/_@~=-]*$' +require_repository_ref BLIISH_REPOSITORY_REF "$BLIISH_REPOSITORY_REF" + +# Reuse the shared SSH key and firewall, but never provision over an existing +# server. +if [ ! -r "$BLIISH_DEPLOY_SSH_KEY" ] && [ ! -r "$BLIISH_HCLOUD_SSH_PUBLIC_KEY" ]; then + require_command ssh-keygen + install -d -m 0700 "$(dirname "$BLIISH_DEPLOY_SSH_KEY")" + ssh-keygen -t ed25519 -f "$BLIISH_DEPLOY_SSH_KEY" -N "" -C "bliishspace-deploy" +fi + +if [ ! -r "$BLIISH_HCLOUD_SSH_PUBLIC_KEY" ] && [ -r "$BLIISH_DEPLOY_SSH_KEY" ]; then + require_command ssh-keygen + ssh-keygen -y -f "$BLIISH_DEPLOY_SSH_KEY" > "$BLIISH_HCLOUD_SSH_PUBLIC_KEY" +fi + +if [ ! -r "$BLIISH_HCLOUD_SSH_PUBLIC_KEY" ]; then + echo "SSH public key not found: $BLIISH_HCLOUD_SSH_PUBLIC_KEY" >&2 + exit 1 +fi + +if [ ! -r "$BLIISH_HCLOUD_FIREWALL_RULES" ]; then + echo "Firewall rules file not found: $BLIISH_HCLOUD_FIREWALL_RULES" >&2 + exit 1 +fi + +if hcloud server describe "$BLIISH_SERVER_NAME" >/dev/null 2>&1; then + echo "Server already exists: $BLIISH_SERVER_NAME" >&2 + echo "Use deploy/hetzner/update.sh for application updates." >&2 + exit 1 +fi + +if ! hcloud ssh-key describe "$BLIISH_HCLOUD_SSH_KEY_NAME" >/dev/null 2>&1; then + hcloud ssh-key create \ + --name "$BLIISH_HCLOUD_SSH_KEY_NAME" \ + --label "app=bliishspace" \ + --public-key-from-file "$BLIISH_HCLOUD_SSH_PUBLIC_KEY" +fi + +if ! hcloud firewall describe "$BLIISH_HCLOUD_FIREWALL_NAME" >/dev/null 2>&1; then + hcloud firewall create \ + --name "$BLIISH_HCLOUD_FIREWALL_NAME" \ + --label "app=bliishspace" \ + --rules-file "$BLIISH_HCLOUD_FIREWALL_RULES" +fi + +render_cloud_init + +# Keep optional hcloud flags in an array to avoid word splitting. +create_args=( + server create + --name "$BLIISH_SERVER_NAME" + --type "$BLIISH_HCLOUD_SERVER_TYPE" + --image "$BLIISH_HCLOUD_IMAGE" + --location "$BLIISH_HCLOUD_LOCATION" + --ssh-key "$BLIISH_HCLOUD_SSH_KEY_NAME" + --firewall "$BLIISH_HCLOUD_FIREWALL_NAME" + --label "app=bliishspace" + --user-data-from-file "$USER_DATA_FILE" +) + +case "$BLIISH_HCLOUD_ENABLE_IPV4" in + 0|false|FALSE|no|NO) + create_args+=(--without-ipv4) + ;; +esac + +case "$BLIISH_HCLOUD_ENABLE_BACKUPS" in + 1|true|TRUE|yes|YES) + create_args+=(--enable-backup) + ;; +esac + +case "$BLIISH_HCLOUD_ENABLE_DELETE_PROTECTION" in + 1|true|TRUE|yes|YES) + create_args+=(--enable-protection delete --enable-protection rebuild) + ;; +esac + +hcloud "${create_args[@]}" + +server_ip="$(hcloud server ip "$BLIISH_SERVER_NAME")" + +# Print the DNS and first-boot checks operators usually need next. +cat <&2 + echo "Run deploy/hetzner/provision.sh first, or set BLIISH_DEPLOY_SSH_KEY." >&2 + exit 1 +fi + +if [ -z "${BLIISH_SSH_HOST:-}" ]; then + require_command hcloud + BLIISH_SSH_HOST="$(hcloud server ip "$BLIISH_SERVER_NAME")" +fi + +known_hosts="$(ssh-keyscan -T 10 "$BLIISH_SSH_HOST" 2>/dev/null || true)" +if [ -z "$known_hosts" ] && [ -n "$BLIISH_DOMAIN" ] && [ "$BLIISH_DOMAIN" != "$BLIISH_SSH_HOST" ]; then + known_hosts="$(ssh-keyscan -T 10 "$BLIISH_DOMAIN" 2>/dev/null || true)" +fi + +if [ -z "$known_hosts" ]; then + echo "Could not collect SSH host keys for $BLIISH_SSH_HOST." >&2 + echo "Check that the server is reachable on port 22, then rerun this script." >&2 + exit 1 +fi + +if ! gh api --method PUT "repos/$BLIISH_GITHUB_REPOSITORY/environments/$BLIISH_GITHUB_ENVIRONMENT" --silent; then + echo "Could not create or update GitHub environment $BLIISH_GITHUB_ENVIRONMENT in $BLIISH_GITHUB_REPOSITORY." >&2 + echo "Check that BLIISH_GITHUB_REPOSITORY points to a repository you control and that gh is authenticated with admin access." >&2 + exit 1 +fi + +gh secret set BLIISH_DEPLOY_SSH_PRIVATE_KEY \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" \ + < "$BLIISH_DEPLOY_SSH_KEY" + +printf "%s\n" "$known_hosts" | gh secret set BLIISH_DEPLOY_KNOWN_HOSTS \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" + +gh variable set BLIISH_BASE_URL \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" \ + --body "$BLIISH_BASE_URL" + +gh variable set BLIISH_SSH_HOST \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" \ + --body "$BLIISH_SSH_HOST" + +gh variable set BLIISH_SSH_USER \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" \ + --body "$BLIISH_SSH_USER" + +gh variable set BLIISH_REPOSITORY_REF \ + --repo "$BLIISH_GITHUB_REPOSITORY" \ + --env "$BLIISH_GITHUB_ENVIRONMENT" \ + --body "$BLIISH_REPOSITORY_REF" + +cat </dev/null; then + run_as_root git -C /opt/bliishspace checkout -B "$repository_ref" "origin/$repository_ref" +else + run_as_root git -C /opt/bliishspace checkout "$repository_ref" +fi + +cd /opt/bliishspace +PNPM_PACKAGE_MANAGER="$(node -p "require('./package.json').packageManager || 'pnpm@11.1.3'")" +run_as_root corepack enable +run_as_root corepack prepare "$PNPM_PACKAGE_MANAGER" --activate +run_as_root pnpm install --frozen-lockfile +run_as_root pnpm build +run_as_bliish bash -lc 'set -a; . /etc/bliishspace.env; set +a; cd /opt/bliishspace; pnpm db:init' +run_as_root systemctl restart bliishspace +run_as_root systemctl status bliishspace --no-pager +REMOTE diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md new file mode 100644 index 0000000..5b588d6 --- /dev/null +++ b/docs/dependency-policy.md @@ -0,0 +1,22 @@ +# Dependency Policy + +Bliish.space keeps runtime dependencies intentionally small so self-hosted instances remain easy to audit and operate. + +## Defaults + +- Prefer the TypeScript standard library, Hono APIs, prepared SQLite statements, and local helpers before adding a package. +- Add a dependency only when it removes meaningful security risk, maintenance burden, or large duplicated code. +- Keep optional infrastructure optional. Do not add a required hosted service, queue, cache, analytics tool, or object store for a core workflow. +- Pin package versions through `pnpm-lock.yaml`. + +## Review Checklist + +Before adding a dependency, confirm: + +- the package is actively maintained; +- the license can be used in a GPL-3.0-only project; +- the package is needed at runtime or can stay in `devDependencies`; +- the same behavior is not already available in the app's shared modules, `src/server`, `src/ui`, or the platform; +- attribution is added to `NOTICE` when required. + +Run `pnpm install`, `pnpm typecheck`, `pnpm test`, and `pnpm build` after dependency changes. diff --git a/docs/deployment/hetzner-cloud-cli.md b/docs/deployment/hetzner-cloud-cli.md new file mode 100644 index 0000000..068d78d --- /dev/null +++ b/docs/deployment/hetzner-cloud-cli.md @@ -0,0 +1,240 @@ +# Hetzner Cloud CLI Deployment + +This is the recommended production path for the official Bliish.space instance and for people running an independent Bliish instance. It creates one small Hetzner Cloud VPS, installs the app with cloud-init, then optionally configures GitHub Actions so future pushes to `main` deploy over SSH. + +We recommend Hetzner Cloud for this project because the small cost-optimized VPS plans fit the app's shape: one Node process, SQLite, local uploads, Caddy, and systemd. The default target is Nuremberg, Germany, which keeps primary app data on infrastructure in Europe under the instance operator's control. That does not replace legal/privacy work, but it is a practical default for a privacy-conscious self-hosted social site. + +The default `cx23` target is deliberately small. It was listed by Hetzner at $4.99/month before VAT as of May 2026, before optional IPv4 and backups. With controlled uploads and normal social activity, treat it as a practical starting point for roughly 10k-50k registered accounts and 1k-5k daily active users; resize when disk, memory, CPU, or response times say the instance has outgrown it. + +## Which Path + +There are two audiences, but the server process is the same: + +| Operator | What You Deploy | GitHub Repo To Configure | +| --- | --- | --- | +| Bliish maintainers | `https://bliish.space` from `bliish-com/bliishspace` | `bliish-com/bliishspace` | +| Self-hosters | your own domain from your own fork or copy | your GitHub repo, for example `yourname/bliishspace` | + +The only difference is what you put in `deploy/hetzner/production.env`. + +If you want automatic deploys from GitHub, use a repository you control. For the Bliish maintainers, that is the upstream repo. For everyone else, that usually means a public fork. GitHub Actions secrets and variables are written to `BLIISH_GITHUB_REPOSITORY`, and GitHub only lets you write environment secrets for repositories where you have enough access. + +If you do not want GitHub auto-deploys, you can clone the upstream repository locally, provision the server, skip `setup-github.sh`, and run `deploy/hetzner/update.sh` manually when you want to update. + +Keep `BLIISH_REPOSITORY_URL` public unless you intentionally extend the deployment with Git credentials. The default VPS bootstrap does a plain `git clone` over HTTPS. + +## Deployment Model + +This is the whole loop: + +1. Your local machine creates the VPS with the Hetzner CLI. +2. cloud-init clones `BLIISH_REPOSITORY_URL` onto the VPS and starts the app with systemd. +3. systemd restarts the app if it crashes. +4. Optional: `setup-github.sh` writes SSH deploy values into `BLIISH_GITHUB_REPOSITORY`. +5. Optional: future pushes to that repository's `main` branch run checks, SSH into the VPS, update the configured ref, rebuild, run `pnpm db:init`, and restart. + +The VPS does not need a GitHub token. GitHub does not need a Hetzner token after provisioning. That keeps the open-source setup simple: Hetzner is used once to create the server, then updates happen over SSH. + +## One-Time Setup + +Install the CLIs: + +```bash +brew install hcloud gh +gh auth login +hcloud context create bliishspace +``` + +Create the production config: + +```bash +cp deploy/hetzner/production.env.example deploy/hetzner/production.env +$EDITOR deploy/hetzner/production.env +``` + +For the official Bliish instance, the branded defaults are already close: + +```bash +BLIISH_DOMAIN=bliish.space +BLIISH_BASE_URL=https://bliish.space +BLIISH_REPOSITORY_URL=https://github.com/bliish-com/bliishspace.git +BLIISH_GITHUB_REPOSITORY=bliish-com/bliishspace +``` + +For a self-hosted instance with GitHub auto-deploys, fork or copy the repo first, keep that fork public unless you add private Git auth, then change at least these values: + +```bash +BLIISH_DOMAIN=social.your-domain.tld +BLIISH_BASE_URL=https://social.your-domain.tld +BLIISH_REPOSITORY_URL=https://github.com/YOUR_GITHUB_NAME/bliishspace.git +BLIISH_GITHUB_REPOSITORY=YOUR_GITHUB_NAME/bliishspace +``` + +`BLIISH_REPOSITORY_URL` is what the VPS pulls from. `BLIISH_GITHUB_REPOSITORY` is where `setup-github.sh` writes deploy secrets and variables. They are usually the same repository, but they are separate so advanced operators can deploy from one repo while configuring another. + +Provision the server: + +```bash +deploy/hetzner/provision.sh +``` + +After the script prints the server IP, point DNS at it. For the default IPv4-enabled deployment, create an A record for your domain. If you also use IPv6, add the AAAA record from: + +```bash +hcloud server ip --ipv6 bliishspace-1 +``` + +Wait for first boot: + +```bash +ssh -i ~/.ssh/bliishspace-deploy root@SERVER_IP 'cloud-init status --wait' +ssh -i ~/.ssh/bliishspace-deploy root@SERVER_IP 'systemctl status bliishspace --no-pager' +``` + +HTTPS is handled by Caddy in this flow. Once DNS points at the server and ports 80/443 are reachable, Caddy provisions and renews the TLS certificate automatically; keep `BLIISH_BASE_URL` set to `https://...`. + +Configure GitHub production deploys: + +```bash +deploy/hetzner/setup-github.sh +``` + +After that, normal deploys are: + +```bash +git push origin main +``` + +GitHub runs typecheck, tests, and build first. If they pass, GitHub SSHes to the VPS and runs [update.sh](../../deploy/hetzner/update.sh). + +## If You Are Self-Hosting + +Use this model: + +1. Fork `bliish-com/bliishspace` on GitHub, or create your own public repository from the source. +2. Clone your fork locally. +3. Edit `deploy/hetzner/production.env`. +4. Run `deploy/hetzner/provision.sh`. +5. Point DNS to the server. +6. Run `deploy/hetzner/setup-github.sh`. +7. Push future changes to your fork's `main` branch. + +When upstream Bliish releases changes you want, merge or rebase upstream into your fork, then push your fork's `main` branch. That push is what deploys your instance. + +If you only want to run the unmodified upstream app and do not care about GitHub auto-deploys, you can skip the fork. Clone `bliish-com/bliishspace` locally, keep `BLIISH_REPOSITORY_URL=https://github.com/bliish-com/bliishspace.git`, skip `setup-github.sh`, and run `deploy/hetzner/update.sh` manually after upstream updates. + +You do not have to modify the app name or branding to test deployment, but a public instance should publish its own privacy, moderation, contact, and backup policies. + +## Production Config + +The local file `deploy/hetzner/production.env` is ignored by git and read by all Hetzner helper scripts. + +Important defaults: + +| Variable | Default | Who Usually Changes It | +| --- | --- | --- | +| `BLIISH_DOMAIN` | `bliish.space` | self-hosters | +| `BLIISH_BASE_URL` | `https://bliish.space` | self-hosters | +| `BLIISH_SERVER_NAME` | `bliishspace-1` | optional | +| `BLIISH_HCLOUD_LOCATION` | `nbg1` | optional | +| `BLIISH_HCLOUD_SERVER_TYPE` | `cx23` | optional | +| `BLIISH_HCLOUD_IMAGE` | `ubuntu-24.04` | rarely | +| `BLIISH_HCLOUD_ENABLE_IPV4` | `true` | optional | +| `BLIISH_HCLOUD_ENABLE_BACKUPS` | `false` | recommended for production | +| `BLIISH_HCLOUD_ENABLE_DELETE_PROTECTION` | `true` | rarely | +| `BLIISH_DEPLOY_SSH_KEY` | `$HOME/.ssh/bliishspace-deploy` | optional | +| `BLIISH_REPOSITORY_URL` | `https://github.com/bliish-com/bliishspace.git` | self-hosters | +| `BLIISH_REPOSITORY_REF` | `main` | optional | +| `BLIISH_ADMIN_USER_ID` | `1` | rarely | +| `BLIISH_MEDIA_CONCURRENCY` | `1` | optional | +| `BLIISH_GITHUB_REPOSITORY` | `bliish-com/bliishspace` | self-hosters | +| `BLIISH_GITHUB_ENVIRONMENT` | `production` | optional | + +`nbg1` is Nuremberg, Germany. `cx23` is the smallest current cost-optimized x86 VPS shape we target. `BLIISH_HCLOUD_ENABLE_IPV4=true` costs a little more than IPv6-only but avoids breaking visitors and deploy machines that do not have reliable IPv6. Upload volume is the main variable for this app, so keep file limits conservative and watch `/var/lib/bliishspace` before treating the estimate above as capacity planning. + +For absolute cheapest networking, set: + +```bash +BLIISH_HCLOUD_ENABLE_IPV4=false +``` + +Only do that if you are comfortable operating an IPv6-only public site. + +## GitHub Values + +`setup-github.sh` creates or updates the GitHub environment named by `BLIISH_GITHUB_ENVIRONMENT` and writes the needed deploy values with the GitHub CLI: + +- secret `BLIISH_DEPLOY_SSH_PRIVATE_KEY` +- secret `BLIISH_DEPLOY_KNOWN_HOSTS` +- variable `BLIISH_BASE_URL` +- variable `BLIISH_SSH_HOST` +- variable `BLIISH_SSH_USER` +- variable `BLIISH_REPOSITORY_REF` + +No Hetzner API token is stored in GitHub. GitHub only needs SSH access to the already-provisioned server. + +## What Gets Installed + +The VPS stores app state on local disk: + +- SQLite: `/var/lib/bliishspace/bliish.sqlite` +- Uploads: `/var/lib/bliishspace/uploads` +- Environment: `/etc/bliishspace.env` +- Code: `/opt/bliishspace` + +cloud-init installs Node.js, pnpm, SQLite, Caddy, the systemd service, and the app. systemd restarts the app if it crashes. + +The account whose id matches `BLIISH_ADMIN_USER_ID` receives the `admin` role. With the default value, create the first account before opening signups widely. All users are kept as accepted friends with that protected admin account and cannot unfriend or block it. + +When group id `1` exists, it is the default group: existing and new users are added automatically, users cannot leave it, and the group cannot be deleted. + +After signing in as the admin account, open `/admin` to manage branding, home page copy, contact details, announcements, moderation, automod, rate limits, users, email outbox, audit logs, and database table counts without editing source files. + +Admin branding and instance settings are stored in SQLite. Updating the code does not reset them. The defaults in the repository are only used when the database has no saved value or when an admin intentionally resets a setting. + +## Manual Updates + +GitHub deploys use the same script you can run locally: + +```bash +deploy/hetzner/update.sh +``` + +The script loads `deploy/hetzner/production.env`, SSHes to the VPS, updates code in `/opt/bliishspace`, installs dependencies, builds, runs `pnpm db:init`, and restarts `bliishspace`. + +It does not replace `/var/lib/bliishspace/bliish.sqlite`, `/var/lib/bliishspace/uploads`, or `/etc/bliishspace.env`. Those hold the instance's users, posts, uploads, admin branding, and production environment. Back them up before maintenance changes; normal deploys preserve them. + +## Backups + +This deployment keeps the app simple, so backups matter. Back up both: + +- `/var/lib/bliishspace/bliish.sqlite` +- `/var/lib/bliishspace/uploads` + +Hetzner server backups are the easiest extra safety net and cost 20% of the server price when enabled: + +```bash +BLIISH_HCLOUD_ENABLE_BACKUPS=true +``` + +Still keep an application archive you can move off the server. The manual [backup commands](hetzner-cloud.md#backups) apply to this CLI flow too. + +## Troubleshooting + +Cloud-init output: + +```bash +ssh -i ~/.ssh/bliishspace-deploy root@SERVER_IP 'tail -n 200 /var/log/cloud-init-output.log' +``` + +App logs: + +```bash +ssh -i ~/.ssh/bliishspace-deploy root@SERVER_IP 'journalctl -u bliishspace -f' +``` + +Caddy logs: + +```bash +ssh -i ~/.ssh/bliishspace-deploy root@SERVER_IP 'journalctl -u caddy -n 100 --no-pager' +``` diff --git a/docs/deployment/hetzner-cloud.md b/docs/deployment/hetzner-cloud.md new file mode 100644 index 0000000..3b06cb3 --- /dev/null +++ b/docs/deployment/hetzner-cloud.md @@ -0,0 +1,232 @@ +# Hetzner Cloud Deployment + +This guide deploys Bliish.space on a single Hetzner Cloud VPS with systemd, Caddy, one SQLite database, and one local upload directory. + +For a shorter `hcloud` and cloud-init flow, see [Hetzner Cloud CLI Deployment](hetzner-cloud-cli.md). + +## Server Shape + +Recommended starting point: + +- Ubuntu or Debian LTS. +- Hetzner `cx23` in `nbg1` for the recommended small German VPS target. With controlled uploads and normal social activity, this is a practical starting point for roughly 10k-50k registered accounts and 1k-5k daily active users. +- A persistent disk large enough for the SQLite database, uploads, logs, and backups. +- A domain name pointed at the server. + +## Firewall + +Allow only: + +- SSH: `22/tcp`, ideally restricted to trusted IPs. +- HTTP: `80/tcp`. +- HTTPS: `443/tcp`. + +Use Hetzner Cloud Firewall, the server firewall, or both. + +## System Packages + +```bash +sudo apt update +sudo apt install -y ca-certificates curl git sqlite3 build-essential python3 caddy +``` + +Install Node.js 24 LTS using your preferred Node distribution method. Then enable pnpm through Corepack: + +```bash +corepack enable +node --version +pnpm --version +``` + +## Service User And Directories + +```bash +sudo useradd --system --create-home --home-dir /var/lib/bliishspace --shell /usr/sbin/nologin bliish +sudo install -d -o bliish -g bliish /var/lib/bliishspace +sudo install -d -o bliish -g bliish /var/lib/bliishspace/uploads +sudo install -d -o root -g root /opt/bliishspace +``` + +## Application Code + +Clone or copy the repository to `/opt/bliishspace`. Official maintainers use the upstream repository. Self-hosters can use their own public fork if they want the server to track their GitHub copy. + +```bash +sudo git clone https://github.com/bliish-com/bliishspace.git /opt/bliishspace +sudo chown -R root:root /opt/bliishspace +``` + +For a fork, replace the clone URL with your repository URL. Private repositories need additional Git credentials and are outside the simple deployment path. + +Install dependencies and build: + +```bash +cd /opt/bliishspace +pnpm install --frozen-lockfile +pnpm build +``` + +## Environment File + +Create `/etc/bliishspace.env`. Replace `bliish.space` with your own domain when running an independent instance: + +```env +BLIISH_BASE_URL=https://bliish.space +BLIISH_DATABASE_PATH=/var/lib/bliishspace/bliish.sqlite +BLIISH_UPLOAD_DIR=/var/lib/bliishspace/uploads +BLIISH_ADMIN_USER_ID=1 +BLIISH_MEDIA_CONCURRENCY=1 +HOST=127.0.0.1 +PORT=3000 +``` + +Lock down the file: + +```bash +sudo chown root:bliish /etc/bliishspace.env +sudo chmod 0640 /etc/bliishspace.env +``` + +Initialize the database: + +```bash +sudo -u bliish -H bash -lc 'set -a; . /etc/bliishspace.env; set +a; cd /opt/bliishspace; pnpm db:init' +``` + +The account that signs up with `BLIISH_ADMIN_USER_ID` receives the `admin` role. With the default value, sign up the first account before opening the instance to others. All users are kept as accepted friends with that protected admin account and cannot unfriend or block it. + +When group id `1` exists, it is the default group: existing and new users are added automatically, users cannot leave it, and the group cannot be deleted. + +After signing in as the admin account, open `/admin` to manage branding, home page copy, contact details, announcements, moderation, automod, rate limits, users, email outbox, audit logs, and database table counts without editing source files. + +Admin branding and instance settings are stored in the SQLite database. The defaults in the repository are only used for a fresh database or an intentional reset. + +## systemd + +Create `/etc/systemd/system/bliishspace.service`: + +```ini +[Unit] +Description=Bliish.space +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=bliish +Group=bliish +WorkingDirectory=/opt/bliishspace +EnvironmentFile=/etc/bliishspace.env +ExecStart=/usr/bin/node /opt/bliishspace/dist/index.js +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectHome=true +ProtectSystem=strict +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native +ReadWritePaths=/var/lib/bliishspace + +[Install] +WantedBy=multi-user.target +``` + +Start the app: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now bliishspace +sudo systemctl status bliishspace +``` + +Logs: + +```bash +sudo journalctl -u bliishspace -f +``` + +## Caddy + +Create a Caddy site for the app: + +```caddyfile +bliish.space { + reverse_proxy 127.0.0.1:3000 +} +``` + +If using Debian/Ubuntu's packaged Caddy, this usually belongs in `/etc/caddy/Caddyfile`. + +Caddy handles HTTPS automatically once DNS points at the server and ports 80/443 are reachable. No manual certificate command is needed for the normal flow. + +Reload Caddy: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## Updates + +```bash +cd /opt/bliishspace +sudo git pull --ff-only +pnpm install --frozen-lockfile +pnpm build +sudo -u bliish -H bash -lc 'set -a; . /etc/bliishspace.env; set +a; cd /opt/bliishspace; pnpm db:init' +sudo systemctl restart bliishspace +``` + +The GitHub Actions workflow runs this same update path automatically after checks pass on `main`, if GitHub deploys are configured with [setup-github.sh](hetzner-cloud-cli.md#github-values). For self-hosted GitHub deploys, configure a fork or repository you control; GitHub needs permission to store the instance's deploy secrets there. + +Updates replace code in `/opt/bliishspace`; they do not replace `/var/lib/bliishspace/bliish.sqlite`, `/var/lib/bliishspace/uploads`, or `/etc/bliishspace.env`. Back those paths up before maintenance changes; normal deploys preserve users, content, uploads, and admin branding. + +## Backups + +Back up the SQLite database and uploads directory. Hetzner server backups are useful for cheap whole-disk recovery, but still keep an application archive you can move off the server. + +```bash +sudo install -d -o bliish -g bliish /var/lib/bliishspace/backups +sudo -u bliish sqlite3 /var/lib/bliishspace/bliish.sqlite ".backup '/var/lib/bliishspace/backups/bliish.sqlite'" +sudo tar -C /var/lib/bliishspace -czf "/var/lib/bliishspace/backups/bliishspace-$(date +%Y%m%d-%H%M%S).tar.gz" backups/bliish.sqlite uploads +``` + +Move backup archives off the server regularly. + +## Restore + +Stop the app: + +```bash +sudo systemctl stop bliishspace +``` + +Restore the database and uploads from a backup archive: + +```bash +sudo tar -C /var/lib/bliishspace -xzf /path/to/bliishspace-backup.tar.gz +sudo install -o bliish -g bliish -m 0640 /var/lib/bliishspace/backups/bliish.sqlite /var/lib/bliishspace/bliish.sqlite +sudo rm -f /var/lib/bliishspace/bliish.sqlite-wal /var/lib/bliishspace/bliish.sqlite-shm +sudo chown -R bliish:bliish /var/lib/bliishspace/uploads +``` + +Start the app: + +```bash +sudo systemctl start bliishspace +``` + +Smoke test: + +- open `/`; +- create or log into an account; +- open `/admin` when signed in as the admin account; +- upload a small profile image; +- open an existing profile page; +- check `sudo journalctl -u bliishspace -n 100`. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..fe7d0ac --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,61 @@ +# Local Development + +Bliish.space is a Hono application at the repository root. + +The app is server-rendered, form-first, SQLite-backed, and self-hosted. It does not require browser JavaScript or external APIs for auth, media, search, analytics, moderation, or storage. + +## Local Setup + +```bash +corepack enable +pnpm install +pnpm db:init +pnpm dev +``` + +Open `http://localhost:3000`. If another app is already using port 3000: + +```bash +PORT=3123 pnpm dev +``` + +The account whose id matches `BLIISH_ADMIN_USER_ID` receives the `admin` role when it signs up. With the default environment, that is the first signed-up account. All users are kept as accepted friends with that protected admin account and cannot unfriend or block it. + +When group id `1` exists, it is the default group: existing and new users are added automatically, users cannot leave it, and the group cannot be deleted. + +After signing in as that account, open `/admin` to manage local branding, site copy, moderation, automod, rate limits, users, email outbox, audit logs, and database table counts without editing source files. + +The app reads environment variables from the shell. If you copy `.env.example` for local overrides, source it before running commands: + +```bash +set -a +. ./.env +set +a +pnpm dev +``` + +Useful checks: + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +Runtime data lives under `data/` by default. SQLite files and user uploads are local state, not source files. + +For a clean local reset, stop the dev server, delete `data/`, then run `pnpm db:init` again. + +## Email + +Verification, reset, and admin emails are always recorded in the local admin outbox. To also send them through SMTP, configure: + +```bash +BLIISH_SMTP_HOST=smtp.example.com +BLIISH_SMTP_PORT=587 +BLIISH_SMTP_USER=... +BLIISH_SMTP_PASSWORD=... +BLIISH_SMTP_FROM=noreply@example.com +``` + +Set `BLIISH_SMTP_SECURE=1` for implicit TLS, usually port 465. Set `BLIISH_SMTP_STARTTLS=0` only for servers that do not support STARTTLS. diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..d240bf4 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,145 @@ +# Self-Hosting + +Bliish.space runs as one Node process with one SQLite database and one upload directory. + +## Who This Is For + +These notes are for both the Bliish maintainers and independent operators. The production process is the same; only the domain name and source repository change. + +- Official maintainers deploy `bliish.space` from `bliish-com/bliishspace`. +- Independent operators deploy their own domain from their own public fork or copy if they want GitHub auto-deploys. +- Operators who do not want GitHub auto-deploys can deploy the upstream repository directly and run updates manually. + +For GitHub auto-deploys, use a repository you control. The deployment helper writes environment secrets and variables to that repository, so a self-hoster normally needs a fork. Keep the fork public unless you intentionally extend the VPS bootstrap with private Git credentials. + +## Requirements + +- Node.js 24 LTS or newer. +- pnpm through Corepack. +- A writable data directory. +- A reverse proxy with TLS for public deployments. + +## Local Setup + +```bash +corepack enable +pnpm install +pnpm db:init +pnpm dev +``` + +Open `http://localhost:3000`. + +## Configuration + +Copy `.env.example` if you want a local starting point, then adjust values for the instance: + +```env +BLIISH_BASE_URL=https://bliish.space +BLIISH_DATABASE_PATH=./data/bliish.sqlite +BLIISH_UPLOAD_DIR=./data/uploads +BLIISH_ADMIN_USER_ID=1 +BLIISH_MEDIA_CONCURRENCY=1 +PORT=3000 +HOST=127.0.0.1 +``` + +The app does not auto-load `.env` files. Source that file in your shell or process manager before running app commands. + +If these variables are unset, the app defaults to `http://localhost:3000`, `./data/bliish.sqlite`, `./data/uploads`, admin user id `1`, media concurrency `1`, port `3000`, and host `0.0.0.0`. The account that signs up with `BLIISH_ADMIN_USER_ID` receives the `admin` role, and all users are kept as accepted friends with that protected admin account. + +When group id `1` exists, it is the default group. Existing and new users are added automatically, users cannot leave it, and the group cannot be deleted. + +The default app does not require cloud credentials or an email provider. Password reset and verification messages are written to the local email outbox visible to admins. + +`BLIISH_UPLOAD_DIR` controls where uploads are written. Public user media is served only from the profile-image, post-image, and theme-song buckets at `/media/pfp/*`, `/media/post-images/*`, and `/media/theme-songs/*`. Keep the upload directory on persistent storage and include it in backups. + +Uploaded user images are normalized locally before storage. `BLIISH_MEDIA_CONCURRENCY` controls sharp/libvips worker concurrency and defaults to `1`, which keeps memory and CPU predictable on small VPS instances. + +## Admin Console + +After the admin account signs in, open `/admin` to manage the instance without editing source code. + +Admins can manage users, reports, automod rules, rate limits, site identity, home page text, contact and legal details, announcements, color theme, blog cleanup, favorites cleanup, the local email outbox, database table counts, and the audit log. Moderators can use `/moderation/reports` for report handling. + +Admin-managed instance settings are runtime data, not source files. They are stored in the SQLite database, so updating application code should not overwrite them. The defaults in the repository are fallbacks for a fresh database or an intentional reset. + +## Production Process + +Build and run: + +```bash +pnpm install --frozen-lockfile +pnpm db:init +pnpm build +pnpm start +``` + +Use a process manager such as systemd, OpenRC, runit, s6, or Docker if that fits the operator environment. Docker should remain optional. + +## Provider Recipes + +- [Hetzner Cloud CLI](deployment/hetzner-cloud-cli.md) +- [Hetzner Cloud manual VPS](deployment/hetzner-cloud.md) + +For GitHub-based auto-deploys, self-hosters should fork or copy the repository first. The VPS pulls code from `BLIISH_REPOSITORY_URL`, and `setup-github.sh` writes deploy secrets to `BLIISH_GITHUB_REPOSITORY`, so those values should point at a repository the operator controls. + +For manual updates without GitHub deploys, a self-hoster may keep `BLIISH_REPOSITORY_URL=https://github.com/bliish-com/bliishspace.git`, skip `setup-github.sh`, and run the update command from the deployment guide when they want to pull upstream. + +## Reverse Proxy + +Terminate TLS at the reverse proxy and forward to the local app port. + +Example nginx shape: + +```nginx +server { + server_name bliish.space; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } +} +``` + +## Backups + +Back up both: + +- the SQLite database; +- the upload directory. + +The SQLite database includes users, posts, moderation records, and admin branding/settings. The upload directory includes profile images, post images, and theme songs. + +For a small instance, the simplest backup is: + +1. Stop the app. +2. Copy `data/bliish.sqlite`. +3. Copy `data/uploads/`. +4. Start the app. + +For live backups, use SQLite's backup tooling or a filesystem snapshot that handles WAL files correctly. + +```bash +sqlite3 data/bliish.sqlite ".backup 'backup/bliish.sqlite'" +``` + +## Restore + +1. Stop the app. +2. Restore the SQLite database file. +3. Restore `data/uploads/`. +4. Check ownership and permissions. +5. Start the app. +6. Open `/home`, `/u/`, and `/account/export.json` for a smoke test. + +## Operational Notes + +- Keep the database and uploads out of the public repository. +- Keep `BLIISH_BASE_URL` accurate so secure cookies work behind TLS. +- Do not put uploads on a filesystem that silently drops writes. +- Monitor disk usage. +- Keep OS packages patched. +- Publish instance-specific privacy, moderation, and backup policies. diff --git a/docs/skin-examples/black-and-grey.html b/docs/skin-examples/black-and-grey.html new file mode 100644 index 0000000..7eea748 --- /dev/null +++ b/docs/skin-examples/black-and-grey.html @@ -0,0 +1,146 @@ + + + diff --git a/docs/skin-examples/cd-case-profile-photo.html b/docs/skin-examples/cd-case-profile-photo.html new file mode 100644 index 0000000..4ca86f9 --- /dev/null +++ b/docs/skin-examples/cd-case-profile-photo.html @@ -0,0 +1,72 @@ + + + diff --git a/docs/skin-examples/fly-away.html b/docs/skin-examples/fly-away.html new file mode 100644 index 0000000..2e1d36d --- /dev/null +++ b/docs/skin-examples/fly-away.html @@ -0,0 +1,182 @@ + + + diff --git a/docs/skin-examples/glossy-dark.html b/docs/skin-examples/glossy-dark.html new file mode 100644 index 0000000..cc6c706 --- /dev/null +++ b/docs/skin-examples/glossy-dark.html @@ -0,0 +1,99 @@ + + + diff --git a/docs/skin-examples/hello-kitty.html b/docs/skin-examples/hello-kitty.html new file mode 100644 index 0000000..059c181 --- /dev/null +++ b/docs/skin-examples/hello-kitty.html @@ -0,0 +1,148 @@ + + + diff --git a/docs/skin-examples/matrix.html b/docs/skin-examples/matrix.html new file mode 100644 index 0000000..9ba0d14 --- /dev/null +++ b/docs/skin-examples/matrix.html @@ -0,0 +1,108 @@ + + + diff --git a/docs/skin-examples/old-school-computer-bezel.html b/docs/skin-examples/old-school-computer-bezel.html new file mode 100644 index 0000000..69848eb --- /dev/null +++ b/docs/skin-examples/old-school-computer-bezel.html @@ -0,0 +1,131 @@ + + + diff --git a/docs/skin-examples/pink-internetcore-anime.html b/docs/skin-examples/pink-internetcore-anime.html new file mode 100644 index 0000000..f26d6de --- /dev/null +++ b/docs/skin-examples/pink-internetcore-anime.html @@ -0,0 +1,236 @@ + + +
+ + + + + + + + + + + + + +
+ + diff --git a/docs/skin-examples/red-web-glitchcore.html b/docs/skin-examples/red-web-glitchcore.html new file mode 100644 index 0000000..00fc3a6 --- /dev/null +++ b/docs/skin-examples/red-web-glitchcore.html @@ -0,0 +1,235 @@ + + +
+ + + + + + + + +
+ + diff --git a/docs/skin-examples/round-profile-photo.html b/docs/skin-examples/round-profile-photo.html new file mode 100644 index 0000000..b6d3e83 --- /dev/null +++ b/docs/skin-examples/round-profile-photo.html @@ -0,0 +1,18 @@ + + + diff --git a/docs/skin-examples/scenecore-emo-layout.html b/docs/skin-examples/scenecore-emo-layout.html new file mode 100644 index 0000000..78e3dc1 --- /dev/null +++ b/docs/skin-examples/scenecore-emo-layout.html @@ -0,0 +1,147 @@ + + + diff --git a/docs/skin-examples/spinning-round-profile-photo.html b/docs/skin-examples/spinning-round-profile-photo.html new file mode 100644 index 0000000..b7b044e --- /dev/null +++ b/docs/skin-examples/spinning-round-profile-photo.html @@ -0,0 +1,18 @@ + + + diff --git a/docs/skin-examples/windows-98.html b/docs/skin-examples/windows-98.html new file mode 100644 index 0000000..05727c6 --- /dev/null +++ b/docs/skin-examples/windows-98.html @@ -0,0 +1,232 @@ + + + diff --git a/docs/skin-examples/windows-vista.html b/docs/skin-examples/windows-vista.html new file mode 100644 index 0000000..a7e7e9e --- /dev/null +++ b/docs/skin-examples/windows-vista.html @@ -0,0 +1,107 @@ + + + diff --git a/docs/skin-examples/windows-xp.html b/docs/skin-examples/windows-xp.html new file mode 100644 index 0000000..4691457 --- /dev/null +++ b/docs/skin-examples/windows-xp.html @@ -0,0 +1,222 @@ + + + diff --git a/docs/skins.md b/docs/skins.md new file mode 100644 index 0000000..0faeab2 --- /dev/null +++ b/docs/skins.md @@ -0,0 +1,484 @@ +# Profile Skins + +Profile skins customize Bliish profile pages with sanitized HTML and passive CSS. + +Skins are edited in the profile editor under **Skin HTML** and shared from **Skins -> Submit skin**. + +The skin field is limited to 20,000 characters. + +Profile skins may be empty. + +Shared skins must save to non-empty sanitized skin HTML. + +Profile and shared skin forms sanitize skin HTML before saving. + +Profile rendering sanitizes profile skin HTML again before rendering. + +## Quick Start + +Use one or more ` + +

Optional custom skin HTML appears on the profile.

+``` + +## Rendering Model + +Skin CSS is collected from sanitized ` +``` + +```html + +``` + +## CSS Selector Rules + +Every skin CSS selector must include at least one supported hook. + +Supported hooks are `[data-skin-page]`, `[data-skin-root]`, `[data-skin-version="2026"]`, and supported `[data-skin-part="..."]` values. + +Bare page selectors are limited to `[data-skin-page]` or `[data-skin-version="2026"]` by themselves. + +Use `[data-skin-root]` or `[data-skin-part="..."]` before descendant selectors. + +This is allowed: + +```css +[data-skin-part="bio"] a:hover { + color: red; +} +``` + +This is dropped because it is a descendant of the body hook rather than a profile part: + +```css +[data-skin-page] a { + color: red; +} +``` + +This is dropped because it has no skin hook: + +```css +body { + color: red; +} +``` + +Selectors with unsupported attribute selectors are dropped. + +Selectors using `:has()`, `:is()`, or `:where()` are dropped. + +Simple `:not(...)` selectors can survive when the `:not(...)` contents only use simple class, id, tag, or spacing characters. + +If any selector in a comma-separated selector list is unsafe, the whole rule is dropped. + +Selectors longer than 500 characters are dropped. + +The sanitizer removes `<` and `>` from CSS before parsing, so do not use child combinators. + +Use descendant selectors instead of `>`. + +## CSS Declaration Rules + +Most ordinary CSS property names are accepted. + +The properties `behavior` and `-moz-binding` are blocked. + +`position:absolute`, `position:fixed`, and `position:sticky` are blocked. + +`position:relative` is not blocked by the skin sanitizer. + +CSS custom properties are accepted when the name starts with `--` and does not use a blocked app-owned prefix. + +CSS values are dropped if they contain CSS escapes, `expression(...)`, `javascript:`, `vbscript:`, `behavior:`, `-moz-binding`, `attr(...)`, `<`, or `>`. + +A declaration containing an unsafe `url(...)` is dropped. + +Other safe declarations in the same rule can still survive. + +CSS comments are removed. + +Malformed CSS rule blocks may be dropped. + +## CSS At-Rules + +`@import` is only kept for `https://fonts.googleapis.com/...` stylesheets. + +Other `@import` rules are removed. + +`@media` rules can survive when the media prelude uses the sanitizer's supported simple characters. + +`@keyframes` rules can survive when the animation name is alphanumeric, underscore, or hyphen. + +Keyframe selectors can be `from`, `to`, or percentages. + +Other CSS at-rules are removed. + +## URLs And Media + +Links and resources use different URL rules. + +Link `href` values can be local paths, hash links, `http:`, `https:`, or `mailto:`. + +Link `rel` is rewritten to `nofollow noopener noreferrer`. + +Link `target` is kept only when it is `_blank`. + +Image `src`, table `background`, and CSS `url(...)` resources can be local app paths or `https:` URLs. + +Resource URLs using `http:`, `data:`, `file:`, `javascript:`, or `vbscript:` are removed. + +Invalid image URLs are replaced with an empty ``. + +CSS declarations with invalid `url(...)` values are removed. + +## Embeds + +Skins can include sanitized ` +``` + +The saved iframe source is normalized to `https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ`. + +## Common Reasons Code Disappears + +The selector did not include a supported skin hook. + +The selector used `body`, `.profile`, `nav`, `table`, or another global selector by itself. + +The selector used an unsupported attribute selector. + +The rule used a blocked at-rule. + +The declaration used an unsafe URL. + +The declaration used a blocked CSS value. + +The HTML tag or attribute is not in the allowed lists. + +The image or iframe URL did not match the sanitizer's URL rules. + +## Developer Verification + +The skin hook list comes from `src/skins/contract.ts`. + +Skin rendering behavior comes from `src/skins/rendering.tsx`, `src/views/profile/page.tsx`, and `src/views/profile/main.tsx`. + +Shell hook behavior comes from `src/shell/layout.tsx`, `src/shell/nav.tsx`, and the layout primitives in `src/shell/page.tsx`. + +Profile part placement comes from `src/views/profile/layout.tsx`, `src/views/profile/sidebar.tsx`, `src/views/profile/main.tsx`, `src/views/profile/details.tsx`, `src/views/profile/actions.tsx`, `src/views/posts/panels.tsx`, and the split layout helpers in `src/shell/page.tsx`. + +Color and radius variables come from `src/skins/colorPalette.ts`, `src/skins/colorPaletteEditor.tsx`, and `public/static/css/features/profile.css`. + +HTML, CSS, URL, and iframe sanitizer behavior comes from `src/server/security/skinHtml.ts`, `src/server/security/css.ts`, `src/server/security/urls.ts`, and `src/server/security/embeds.ts`. + +Skin create, edit, preview, and apply behavior comes from `src/routes/skins/index.tsx`. + +Profile skin save and generated color-skin behavior comes from `src/routes/profile/index.tsx` and `src/routes/profile/edit/actions.ts`. + +When changing skin behavior, update this document in the same change. diff --git a/docs/theme-tokens.md b/docs/theme-tokens.md new file mode 100644 index 0000000..d12ae8e --- /dev/null +++ b/docs/theme-tokens.md @@ -0,0 +1,492 @@ +# Theme Tokens + +Theme tokens are the internal CSS custom-property contract for the app shell and reusable UI components. + +This document is for maintainers changing the app theme system. + +Skin authors should use [Profile Skins](skins.md) instead. + +Do not merge this document into `skins.md`. + +`skins.md` describes the user-facing skin HTML contract and sanitizer boundary. + +This document describes how app and admin themes feed the shared component palette. + +## Source Files + +- `public/static/css/core/tokens.css` defines default theme tokens, sizing tokens, and component-facing aliases. +- `public/static/css/themes/dark.css` defines the cookie-selected dark override layer. +- `src/theme/colorPalette.ts` defines the admin color-palette model and derived color calculations. +- `src/theme/themeCss.ts` writes `/branding.css` from a saved admin color palette. +- `src/routes/system/theme.ts` serves `/theme.css` and handles the light/dark toggle cookie. +- `src/routes/system/branding.ts` serves `/branding.css`. +- `src/server/db/branding.ts` stores and reads the admin color palette. +- `src/views/staff/siteSettings.tsx` renders the admin color-theme form. +- `public/static/css/features/profile.css` maps profile skin variables into the same component-facing aliases on skinned profile pages. + +## Token Layers + +The theme system has four layers. + +### 1. Admin Palette Source + +The admin color-theme form edits eight source colors. + +These source colors are defined by `colorPaletteTokens` in `src/theme/colorPalette.ts`. + +| Palette token | Admin label | Default | +| --- | --- | --- | +| `chrome` | Header | `#7c3aed` | +| `chromeText` | Header text | `#ffffff` | +| `accent` | Accent | `#7c3aed` | +| `accentText` | Accent text | `#ffffff` | +| `link` | Link | `#6b21a8` | +| `backdrop` | Backdrop | `#e6e3ea` | +| `page` | Page | `#ffffff` | +| `surface` | Panel | `#ffffff` | + +The admin form field names are `color_chrome`, `color_chromeText`, `color_accent`, `color_accentText`, `color_link`, `color_backdrop`, `color_page`, and `color_surface`. + +Only six-digit hex colors are accepted by the palette parser. + +Invalid submitted palette values fall back to the current palette value or the default palette. + +The saved admin palette is stored as JSON in the `branding.palette` setting. + +Saving the default palette removes the saved `branding.palette` setting. + +### 2. App Theme Variables + +`public/static/css/core/tokens.css` defines default `--app-theme-*` fallback values. + +`src/theme/themeCss.ts` writes these `--app-theme-*` variables into `/branding.css` when admin branding is customized: + +```text +--app-theme-backdrop +--app-theme-page +--app-theme-surface +--app-theme-chrome +--app-theme-chrome-text +--app-theme-accent +--app-theme-accent-text +--app-theme-link +--app-theme-link-hover +--app-theme-surface-link +--app-theme-surface-link-hover +--app-theme-page-text +--app-theme-surface-text +--app-theme-muted +``` + +`--app-theme-focus` exists in the default CSS and is used as the fallback for `--theme-focus`. + +The admin color-theme form does not currently edit `--app-theme-focus`. + +### 3. Active Theme Variables + +Components do not consume admin palette tokens directly. + +They consume active `--theme-*` variables. + +Default active theme variables are defined in `public/static/css/core/tokens.css`. + +Customized admin branding writes these active variables into `/branding.css`: + +```text +--theme-backdrop +--theme-page +--theme-surface +--theme-chrome +--theme-chrome-text +--theme-accent +--theme-accent-text +--theme-link +--theme-link-hover +--theme-surface-link +--theme-surface-link-hover +--theme-page-text +--theme-surface-text +--theme-muted +``` + +These active theme variables are CSS-only defaults and are not written by `src/theme/themeCss.ts`: + +```text +--theme-danger +--theme-danger-text +--theme-success +--theme-success-ink +--theme-success-text +--theme-focus +--theme-page-grid-a +--theme-page-grid-b +--theme-toggle-dark-display +--theme-toggle-light-display +``` + +### 4. Component-Facing Aliases + +Reusable component CSS consumes `--color-*`, `--surface-*`, `--page-background`, and `--shadow-*` aliases. + +Those aliases are resolved for both `:root` and `[data-skin-page]` in `public/static/css/core/tokens.css`. + +The shared resolver lets admin themes and profile skins use the same component formulas. + +Component-facing color aliases are: + +```text +--color-page +--color-canvas +--color-surface +--color-surface-raised +--color-surface-tint +--color-text +--color-text-muted +--color-text-on-bright +--color-link +--color-link-hover +--color-brand +--color-brand-accent +--color-brand-header +--color-brand-nav +--color-brand-soft +--color-brand-border +--color-panel-accent +--color-panel-accent-soft +--color-panel-rule +--color-container-border +--color-danger +--color-danger-text +--color-danger-soft +--color-danger-border +--color-success +--color-success-ink +--color-success-text +--color-success-soft +--color-success-border +--color-field +--color-field-disabled +--color-field-border-light +--color-field-border +--color-field-border-dark +--color-focus +--color-link-focus-bg +--color-row-alt +--color-shadow-panel +--color-shadow-soft +--color-shadow-pressed +--color-shadow-card +--color-button-primary-bg +--color-button-primary-text +--color-button-primary-border-light +--color-button-primary-border-dark +--color-button-primary-hover-bg +--color-button-primary-hover-text +--color-button-secondary-bg +--color-button-secondary-text +--color-button-secondary-border-light +--color-button-secondary-border-dark +--color-button-secondary-hover-bg +--color-button-secondary-hover-text +--color-button-danger-bg +--color-button-danger-text +--color-button-danger-border-light +--color-button-danger-border-dark +--color-button-danger-hover-bg +--color-button-danger-hover-text +``` + +Component-facing surface aliases are: + +```text +--surface-background +--surface-background-raised +--surface-background-soft +--surface-background-tint +--surface-border +--surface-border-soft +--surface-rule +--surface-shadow +``` + +Page and shadow aliases are: + +```text +--page-background +--shadow-panel +--shadow-button +--shadow-field +--shadow-pressed +--shadow-photo +--shadow-card +--column-divider-shadow +``` + +## Derived Colors + +`src/theme/colorPalette.ts` derives readable and hover colors from the eight admin palette source colors. + +`deriveColorPalette()` returns: + +```text +accent +accentText +backdrop +chrome +chromeText +muted +page +pageLink +pageLinkHover +pageText +surface +surfaceLink +surfaceLinkHover +surfaceText +``` + +`pageText` and `surfaceText` are chosen as black or white based on contrast. + +`muted` is mixed from `surfaceText` and `surface`. + +`pageLinkHover` and `surfaceLinkHover` are adjusted toward readable contrast against their background. + +The CSS layer derives borders, soft surfaces, fields, shadows, and button border colors with `color-mix(...)`. + +## Surface Meanings + +`--theme-backdrop` is the browser background behind the app container. + +`--theme-page` is the page canvas inside the app container. + +`--theme-surface` is used for panels, cards, composers, profile actions, and form surfaces. + +`--theme-chrome` is used for navigation, header-like chrome, and panel headings. + +`--theme-accent` is used for primary actions. + +`--theme-link` is the normal content link source color. + +`--theme-page-text` is used for text on the page canvas. + +`--theme-surface-text` is used for text on panels and surfaces. + +`--theme-muted` is used for secondary text. + +`--theme-focus` is used for focus styling. + +## Default Light Theme + +The default light theme is defined in `public/static/css/core/tokens.css`. + +The default theme color source variables such as `--theme-chrome`, `--theme-accent`, `--theme-link`, `--theme-backdrop`, `--theme-page`, and `--theme-surface` point at default `--app-theme-*` fallback values. + +The default `--theme-page-grid-a` and `--theme-page-grid-b` variables derive a subtle page-grid background from `--theme-backdrop`. + +## Dark Theme + +The dark theme is a small override layer in `public/static/css/themes/dark.css`. + +`/theme.css` imports `public/static/css/themes/dark.css` only when the `app_theme` cookie is `dark` and admin branding is not customized. + +The dark override is scoped to `:root:not([data-theme-lock="light"])`. + +Dark mode overrides: + +```text +color-scheme +--theme-backdrop +--theme-page +--theme-surface +--theme-page-text +--theme-link +--theme-muted +--theme-danger +--theme-success-text +--theme-toggle-dark-display +--theme-toggle-light-display +``` + +The dark override does not write `--app-theme-*` values. + +## Admin Branding + +Customized admin branding is served from `/branding.css`. + +`/branding.css` is empty when branding is not customized. + +When branding is customized, `src/theme/themeCss.ts` writes both `--app-theme-*` fallback values and active `--theme-*` values to `:root`. + +Customized admin branding disables the light/dark theme toggle in the shell. + +Customized admin branding locks the document with `data-theme-lock="light"` so the dark override does not apply. + +Resetting the color theme deletes the saved palette and returns the app to the default light/dark behavior. + +## Profile Skins + +Profile skins do not write this global contract directly. + +Skin CSS should use `--skin-palette-*` variables scoped to `[data-skin-page]`. + +`public/static/css/features/profile.css` maps skin palette variables into page-local `--skin-*` variables. + +`public/static/css/features/profile.css` then republishes those values through local `--theme-*` variables on `[data-skin-page]`. + +Because component-facing aliases are resolved for `[data-skin-page]`, skinned profile pages use the same component formulas as admin themes. + +Missing skin palette variables inherit the current `--app-theme-*` values. + +Profiles without sanitized skin CSS inherit the normal site theme. + +The skin sanitizer drops custom properties whose names start with `--theme-`, `--app-theme-`, or `--color-`. + +Skin authors should use the public skin variables and hooks documented in [Profile Skins](skins.md). + +Profile skin radius variables map into shared radius tokens on `[data-skin-page]`: + +| Skin variable | Shared token | +| --- | --- | +| `--skin-radius-panel` | `--radius-panel` | +| `--skin-radius-photo` | `--radius-media` | +| `--skin-radius-control` | `--radius-subtle` | + +`--skin-radius` is a fallback used by the skin radius variables. + +## Non-Color Tokens + +`public/static/css/core/tokens.css` also defines non-color tokens. + +These tokens are not controlled by the admin color-theme form. + +Base color constants: + +```text +--color-white +--color-ink +``` + +Typography tokens: + +```text +--font-body +--line-height-body +--font-size-main +--font-size-footer +--font-size-note +--font-size-caption +--font-size-small +--font-size-nav +--font-size-nav-link +--font-size-utility-action +--font-size-card-heading +--font-size-panel-heading +--font-size-profile-heading +``` + +Layout and size tokens: + +```text +--container-width +--container-min-height +--content-measure +--nav-side-min +--audio-width +--avatar-size +--avatar-compact-size +--profile-photo-size +--profile-edit-photo-size +--person-card-width +--person-card-min-height +--profile-person-card-width +--details-label-width +--discussion-author-width +--column-compact +--column-profile +--column-wide +--column-aside-min +--profile-sidebar-min +``` + +Spacing tokens: + +```text +--space-1 +--space-2 +--space-3 +--space-4 +--space-5 +--space-6 +--space-7 +--space-8 +--space-9 +--space-10 +--space-11 +--space-12 +--layout-stack-gap +--panel-body-gap +--list-stack-gap +--space-main-y +--space-main-x +--space-nav-links-y +--space-nav-links-x +--link-separator-gap +--article-title-offset +--icon-label-gap +--icon-offset-y +``` + +Border, radius, control, and table tokens: + +```text +--border-thin +--border-medium +--radius-panel-default +--radius-media-default +--radius-panel +--radius-media +--radius-control-default +--radius-subtle +--text-decoration-hairline +--text-underline-offset +--icon-size +--brand-icon-size +--avatar-glyph-size +--control-min-height +--control-pad-y +--control-pad-x +--button-active-shift +--disabled-opacity +--focus-outline-width +--focus-offset +--table-cell-y +--table-cell-x +--contact-cell-min-height +--nav-search-width +--nav-link-min-height +``` + +## Maintainer Rules + +Add a new admin-controlled source color only if it belongs in the eight-token color palette model or if that model is intentionally expanded. + +If the admin palette model changes, update `src/theme/colorPalette.ts`, `src/theme/themeCss.ts`, the admin color form, and this document. + +If a component needs a reusable color, prefer adding a component-facing alias in `public/static/css/core/tokens.css` rather than reading `--app-theme-*` directly. + +If a profile skin should control a new theme concept, add a `--skin-palette-*` or `--skin-*` token in `public/static/css/features/profile.css` and update [Profile Skins](skins.md). + +Do not require skin authors to set `--theme-*`, `--app-theme-*`, or `--color-*`. + +## Change Checklist + +When changing theme tokens: + +- update `public/static/css/core/tokens.css`; +- update `src/theme/colorPalette.ts` and `src/theme/themeCss.ts` if admin branding should control the token; +- update the admin color-theme form if admins need to edit the token; +- keep `public/static/css/themes/dark.css` as a small override layer; +- verify `/branding.css` output when branding is customized; +- verify `/theme.css` output with and without the dark cookie; +- check profile skins still use their separate skin contract; +- update [Profile Skins](skins.md) when skin-facing variables change. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..0d90769 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,150 @@ +# Threat Model + +Bliish.space intentionally allows user-controlled profiles. That makes security boundaries explicit and non-optional. + +## Assets + +- Account credentials. +- Session tokens. +- CSRF tokens. +- Email addresses. +- Profile data. +- Uploaded profile images, post images, and audio. +- Private profile visibility. +- Admin actions, reports, and automod rules. +- SQLite database and uploads directory. + +## Actors + +- Anonymous visitor. +- Signed-up user. +- Profile owner. +- Friend. +- Moderator. +- Admin. +- Instance operator. +- Malicious user. +- Compromised reverse proxy or host. + +## Trust Boundaries + +- Browser to Hono request. +- Cookie/session middleware to route handlers. +- Form parser to validators. +- Sanitizer to HTML renderer. +- Upload parser to filesystem. +- Route handler to SQLite query module. +- Admin-only routes. +- Reverse proxy to app process. + +## Primary Risks + +### Cross-Site Scripting + +Risk: profile bio, skin HTML, posts, comments, blogs, private messages, and reports render user content. + +Controls: + +- sanitize before storage; +- restrict tags, attributes, schemes, and inline styles; +- allow skin CSS only through the skin sanitizer, which strips active code, unsafe URL schemes, arbitrary CSS attribute selectors, high-risk selector functions, and absolute/fixed/sticky positioning, while keeping page-level skin selectors variable-only and preserving documented profile skin hooks; +- allow skin resource URLs only when they are local paths or HTTPS URLs; +- allow iframe embeds only for host/path-validated mainstream player URLs, with fixed sandbox and referrer policy attributes; +- avoid rendering raw input; +- keep sanitizer tests near sanitizer code; +- render stored sanitized HTML through the shared `trustedHtml` boundary, and treat any direct `dangerouslySetInnerHTML` use as security-sensitive. + +### CSRF + +Risk: form-first app has many mutating POST routes. + +Controls: + +- form-based mutations require CSRF tokens; +- mutating form actions use per-action rate limits backed by short-lived SQLite counters keyed to accounts or submitted form subjects; +- invalid tokens return 403; +- cookies use SameSite=Lax; +- destructive forms stay POST-only. + +Email verification is the exception: `/verify/:token` consumes a one-time token stored as a hash in SQLite because the token itself authorizes that action. + +### Session Theft + +Risk: stolen session token gives account access. + +Controls: + +- random opaque tokens; +- store only token hashes; +- HTTP-only cookie; +- secure cookies when base URL is HTTPS; +- revocation on logout. + +### Upload Abuse + +Risk: executable files, oversized files, or disguised content. + +Controls: + +- size cap; +- request body size cap before form parsing; +- allowed MIME and extension; +- file signature validation; +- local image normalization before storage; +- random filenames; +- separate upload buckets for profile images, post images, and theme songs; +- uploads stored under the configured upload directory; +- production deployment docs place uploads under `/var/lib/bliishspace/uploads`; +- local development defaults use `data/uploads`, which is ignored by git. + +### Authorization Bypass + +Risk: user mutates or reads resources they should not access. + +Controls: + +- route-level auth checks; +- owner/admin checks before edit/delete; +- moderator checks for report queue actions, with role hierarchy checks before staff can delete content or ban an author; +- admin-only automod rule management, with rule length limits, regex compile validation, scan length caps, default critical rule packs, evasion-aware text normalization, and review matches routed through the same moderated report queue; +- private profile checks on direct profile, profile friends, profile blog, and wall routes; +- group post creation, props, and comments require group membership; +- protected admin friendship and default group membership checks prevent users from disconnecting from the instance admin account or leaving group id `1`; +- per-entry blog privacy checks for public, friends-only, and private diary entries; +- admin routes require the `admin` role; report moderation routes require a staff role with moderation capability. + +### Open Redirect + +Risk: attacker abuses redirect after delete actions. + +Controls: + +- redirects from request headers are restricted to local paths or same-origin URLs. + +### Third-Party Profile Resources + +Risk: customized skins can cause visitor browsers to fetch HTTPS images, CSS backgrounds, Google Fonts resources, or whitelisted embedded players chosen by profile authors. + +Controls: + +- remote resources are limited to HTTPS URLs by the skin sanitizer; +- iframe embeds are limited to mainstream player URLs such as YouTube, SoundCloud, Vimeo, Spotify, Bandcamp, TikTok, and Dailymotion, rewritten or validated by the skin sanitizer, sandboxed, and constrained by CSP `frame-src`; +- ordinary user text fields do not allow image tags; +- instance operators can tighten `sanitizeSkinHtml` and CSP if they want local-only skin assets. + +### Data Loss + +Risk: SQLite file or uploads are lost or corrupted. + +Controls: + +- WAL mode; +- documented backup and restore; +- uploads and database kept in predictable directories. + +## Residual Risks + +- Sanitizer policy may need tightening as skin support expands. +- Instance operators can weaken privacy with proxy logs or CDN configuration. +- New features may miss moderation edge cases. +- Deleted data can remain in backups outside the app. diff --git a/package.json b/package.json new file mode 100644 index 0000000..92a5dbf --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "bliishspace", + "version": "0.0.0", + "description": "Lightweight, lightning-fast, open-source private social media you can self-host and customize, with no tracking", + "license": "GPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/bliish-com/bliishspace.git" + }, + "homepage": "https://github.com/bliish-com/bliishspace", + "author": { + "name": "Bliish", + "url": "https://bliish.com" + }, + "private": true, + "type": "module", + "packageManager": "pnpm@11.1.3", + "engines": { + "node": ">=24.0.0", + "pnpm": ">=11.0.0" + }, + "scripts": { + "dev": "tsx watch src/index.tsx", + "start": "node dist/index.js", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "assets:brand": "tsx src/scripts/generateBrandAssets.ts", + "assets:brand:clean": "tsx src/scripts/generateBrandAssets.ts --clean", + "build": "pnpm clean && tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "db:init": "tsx src/server/db/init.ts", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "2.0.3", + "@node-rs/argon2": "2.0.2", + "better-sqlite3": "12.10.0", + "hono": "4.12.19", + "lucide-static": "1.16.0", + "sanitize-html": "2.17.4", + "sharp": "0.34.5" + }, + "devDependencies": { + "@types/better-sqlite3": "7.6.13", + "@types/node": "24.12.4", + "@types/sanitize-html": "2.16.1", + "tsx": "4.22.2", + "typescript": "6.0.3", + "vitest": "4.1.6" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7354f88 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1939 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: 2.0.3 + version: 2.0.3(hono@4.12.19) + '@node-rs/argon2': + specifier: 2.0.2 + version: 2.0.2 + better-sqlite3: + specifier: 12.10.0 + version: 12.10.0 + hono: + specifier: 4.12.19 + version: 4.12.19 + lucide-static: + specifier: 1.16.0 + version: 1.16.0 + sanitize-html: + specifier: 2.17.4 + version: 2.17.4 + sharp: + specifier: 0.34.5 + version: 0.34.5 + devDependencies: + '@types/better-sqlite3': + specifier: 7.6.13 + version: 7.6.13 + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + '@types/sanitize-html': + specifier: 2.16.1 + version: 2.16.1 + tsx: + specifier: 4.22.2 + version: 4.22.2 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.6 + version: 4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.0.3': + resolution: {integrity: sha512-a0jV+/HRe3G5zjFID3zObAQFdkl6zpxTuqktdDDXS3MJKcrZIkB8OkLpNBlY/WXFqv2HF4a0takPej+aNFczWA==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} + engines: {node: '>= 10'} + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + hono@4.12.19: + resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} + engines: {node: '>=16.9.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lucide-static@1.16.0: + resolution: {integrity: sha512-/EH96rDLRLNKaxuhqHHbmUPx3+qHS/kQRzI1ZGZqnxmh6ljX9yX0xC2UZlQSfBE/n84eNIaxKsv9P8sMiOdCww==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + sanitize-html@2.17.4: + resolution: {integrity: sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.2: + resolution: {integrity: sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@hono/node-server@2.0.3(hono@4.12.19)': + dependencies: + hono: 4.12.19 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@node-rs/argon2-android-arm-eabi@2.0.2': + optional: true + + '@node-rs/argon2-android-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-x64@2.0.2': + optional: true + + '@node-rs/argon2-freebsd-x64@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.0.2': + optional: true + + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true + + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 + + '@oxc-project/types@0.130.0': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.12.4 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + base64-js@1.5.1: {} + + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chai@6.2.2: {} + + chownr@1.1.4: {} + + convert-source-map@2.0.0: {} + + dayjs@1.11.20: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + detect-libc@2.1.2: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escape-string-regexp@4.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + hono@4.12.19: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-plain-object@5.0.0: {} + + launder@1.7.1: + dependencies: + dayjs: 1.11.20 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lucide-static@1.16.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.0 + + obug@2.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parse-srcset@1.0.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + safe-buffer@5.2.1: {} + + sanitize-html@2.17.4: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.14 + + semver@7.8.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.2: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + util-deprecate@1.0.2: {} + + vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + esbuild: 0.28.0 + fsevents: 2.3.3 + tsx: 4.22.2 + + vitest@4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c4ac1e3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + better-sqlite3: true + esbuild: true + sharp: true diff --git a/public/static/brand/apple-touch-icon.png b/public/static/brand/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..53cd3d4b89d17ab3d5c7ee3d38a22b680406b4f9 GIT binary patch literal 2890 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 literal 0 HcmV?d00001 diff --git a/public/static/brand/favicon.svg b/public/static/brand/favicon.svg new file mode 100644 index 0000000..df7642c --- /dev/null +++ b/public/static/brand/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/static/brand/icon-1024.png b/public/static/brand/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..f8681d0bca446a5d24f5fa290dfd365485c33eab GIT binary patch literal 35261 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 I=akR{0P!HmnE(I) literal 0 HcmV?d00001 diff --git a/public/static/brand/icon-192.png b/public/static/brand/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..764fa17852a6b2fa19cf4282bf4d2c0167f7de38 GIT binary patch literal 3170 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 Zy*s3$Le^g659p@>u(fiqEVE#+{|y6zaOnU5 literal 0 HcmV?d00001 diff --git a/public/static/brand/icon-512.png b/public/static/brand/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..910547de65d255c6418ac26120e8d62f268d63f5 GIT binary patch literal 12390 zcmeHtc{r5q`}atpsL^9hh3Y9vw#wR!QIR&rq_ULZNkt@E6El`cDqE;j3ZYTiLb5Mo zc?uz0)-cF!#F$|WgPHf9xu=@%AHVm%clqPnadZ^deO=dio%{JY&rowSlZ_kXHb5Yd zjVDhSorOTw0RLSB5fK9ZnfGaD0{^UcKVj_!frv_Q{t7^1<7FWbMaW5`BjHF?b;2>iYHWp&TbIZ)j9a` z-kEz3uk94PqPXMAPQjPKaX z1HTYifZQ?M#UUH`#o9Q?k)+K$s_$Ar6k9~N+aZEgnug*|YkAM>7i2*0B+-q*BVON^ zmxUZSad!~BYc2m{XV>!6u#S(0p8|X|Xbar^LqiJ0@DB|RcYe{3QR?*tC=^LTc{9~vs<`Mz@EXYnn*uN3u#M1FCr#Aoo&o|X;+ zs2Jq`D;D2T;*(OV3jg!|s79q>-g@yr%An6Li0y)Fe<_14zdB7gCkhMQ5qqNO1B$r{ zu)E(H1ml+;T7XZ3eEFqk`dfN{2JPjeZSao{E89>4flV#Uom?dh)IMMj`J%Y~t}s{G z#KriYS0%xJ1i$Hg5dI@$#qPDgrKcet&#&u|zx6DTK$kB9Hf5@VcF8dw$fF~Ft%ZX2 zBFp~_eim2pv)G=W#UJ=ttjo{hZGTv7JO$^Ntc@LLOX4;YfdYy_fAd_0p$5M(BKc{s z}$8N!hQ`wkd)LWG3;@(!aJ z9Ve;dT}`2+sP_+Zm2NzAd0w`a@pB^7F`YJGOB^9BlEd9i)D< zfa``HJ@P(llH4WDOl4(nS1m4nMoQOKA1tWZ6&zK2=^Sf+_&c`fz z^2HCU<5l|0C+%;<6}ZC06^gG1Y@2;Nj}?cBsM5i2mSKzI_;{OFZEmHV+1<9&-X~r` ztAbjNf}S<6Hb}+buv(KG<2LLi`vEI5ZJyA#-)G4#(lkK<_-rTdvrj?6Zt)6?Y5gZS zV&jFkI#7~^IyPY~^yq0AH!QT?fY#5=j&_vYa<-ZMb`2?bVDN%u1dmKFxN>$qDS!Gy zKKsyl-0kJv2cL{1XB7{1gI3Wvm()jM{akHGkE2d}J%_Ul=>Ae0{B3T3*l`@!IT5Vo z#o+i`blEuO@wYL@5L)_J^g8I#PrR$QLDkq_yN~ye#Mo@Lq(ls?e!DlaBn1=MNCP== z5GEVY`nianxti8hf1Dy(H0g$qgEik3e!T+>rKuIdIssl`jRD(RUa!^;WeWOTQr4mn zobG|+~d z@%DXpXO=O_1>|2lBZnPG446Y|uia)KZz49YuZYm`~kF}JQgXVg7NV5@p z>p1e$0zIS=5vGi=$;Fb&+g%h^(TBAa9$NYb_<5=Uyl(^(8&24o5WaMRPr>3esA*c#1FM!{s#8( z6g@!xX;(uJec+*mKo{QM)03l}4?qEbX%2du+kcXH8>Mia`Y^K&`xi`P3&{MP>d^W| zhxYv>&0E(zvw$tvcv~6-y;pUR88KyX@hVk8k6!ZLOC2nyd^ICpKp`d*R2+AYpPp5` zGQtfiA$~9)uce!!?#J?LEbffZcZa0fg_QY!sKs1lX^78xR z3#!-jxvTnI_wsB4?%xC6e}Ymq8@{Io@H+3%iK&U_#};EnmHr+#1+E(eubY>Dh?~cR zyPa-d{Zc}@+Ulvps? zblO(z4TTdnAdtJAGF1;zZg0_1sh?t*|CV%mWizC}l?yUeNl_6Gg%{-WA%$m_y%o|t z6JrqoQ-H7rfm|fr>suZ`FBupznG*MbEO^5XDM5)un`qp2f{`5i^FE>pCDe7HQi7A+ ze8YagtOkg>pU(H7qw`bPpS5{3MF?~3w%tX1cjpJunhXS7xu64-_;uJ~^~Slg*@&lB z&`Ua&tCL4^GI=~2u6pX&`3{X&&Z&nhl9A92znq>N)Q=g{Szd4&_&W0{s8{2F7rG1Y zX>(b3qxh82CDHf7_n-3&a8AB&R>$IOHvDO57Q5$)Rw}ew`}Y#eXSJ_=YSUFhqTOMW zjpSoF5#a3;Wx~zy4={Skv6mCymt)e7_$wE*f+W9U*gNMKa97@r`^lbw?z5Q$h1TTz z;l@G%_c%Z!Q9(@e8_A$%=~JrSpFwOI(dEqxUItJGMWD1fJNOs{r2#q4LtX2 z4b3WcKgwBX^JbBaF{v`u1;6(^jq(lc&rm_=m~AH;=eY7X_g;APfjWUf2BIq}7>AmY z1!IUcR4DQAH?j#kGg`@E+kDPrvblPg>)bE#P3Ya~`|HHBP1A^XLiE?&$3fS6t3T+~ z#ZA_%)Nc!>!W~HllD%M#7Xq;QX7$Ymh4VPC?`r&fo0H$~b3J!7YF}eq*~$MDs6;?+ z56$FR4ZQr+e=by%JA?u|;MC}9XLE%xSDteohZoI#eEA%aehUa{?f-GPdH>4sb7We@ z{BA>ds?%Fg$lrWEI66j2eHiTlq9L;_=<-FLJFE!;(h;sEOPda)v*we!FM!W`Lmey@ zexAb7@dP6N<$vz+oRi7FxJ$^)0*^Dlk@4#ajgc}Gwfk{D$C$o7PUt6pGUvAqedzPX z{%Zm^(*T=sMzYtd^8BtoG@;!0IvEr4S6Z3asp(0C>ot(``cRHh05*%6j!{a7uPPOM z=UFDa`-8I^IX^`IJC_Jy?8UL33=s3&sDw!V2w60daknW3# z3RL4Bhp->4Z;l8z@x8RMZzKbsz4%iXlRcx^a+Q;m&sNC#EG=Quy7rQlOtA~5TG?Zo zEu-B0pVCmNC1Vy+8=3GyMCZ(?Nn-h&r8?ia&?`wRrjKLah9WajbI$UDwl~)RmEP=Y zF|ks6zn^-yVDwmH|2Ln6BdaqE5NJ|{uh^|EoNRyPq)-Ch{r<;vHWBAnkjBnk7lDsxyUYng1(2>wKomXw=dgHfK`$lOQ^W_Y zTgV+$w+yUSRA3f#p;861j$mkTFVJ(YI}mQ7`mdG+Hq#566V1FZAgj!+`I?>v0*Q7s z4xZfBE;taEG1dVSDlzEeWVWF@_0|bpdx&e;c}e0~pCd*EpbZj*eJj-NwVtmT~SguWIO zCSbllZ1j-q;!?iXP+fFdj6T!hnO-qF_PbSQvqlSs4EPJlhj;Z%h0s1KH9fa;oJ@X7=Epf3O*db9qYy{f=JAh(6Cld+)n3nQ1ufMB zy2M-l$+XGKrJ+~T?ZhM_UKd#{+l_HTgJ4zO8#baQTVKUAok>s!DuXq= z%3!?wjz?5>m=exa+^=GQnNt3tX^ADKD`o7$iw|nho7*)qaTTWQG@TeqWTBsnGp!8B z1fTPQgg+3*oV-^GFG|-CDGPbGH+APGD;Y482hOA4GzAYH`BlaCOjSKXMn)n@`FZC} zgc_3lM{B;EX8WD-WCOK8IOs>Kf;=_-e!P~72-pcYTBP4h;AsEcux;~H{U`9nFdvG} zx&X}7zeOI6<=v95mxzTplICLHW2;(yp%NOHm92#uIOPrk$xth>6$P&{b4pXxR@k zbFeDyfVtXx#wERslD%$RgIOUvzL=-D43R9qew-M#QlCnX0iC#y`#Z5qOT`{kmdmwT zDl#jwVf+TAt8MLQqAhFtaH5qIWqIZ&nNp*6ThqLqml?w*P77r*R~CJGtJ{Xv-O%Et z3g|5utNDUXu>yKvO6y7a%G>i-C;Ds~IrM`IeNIz< zpE2|-XX{W|U0k{{n}J^)Xu!W)acsb+tavvdl2>x;5xFb5IU&Ykk`dHO6)wK%uFnmL zE0cwGp~wN^q4N2@5{{$xXzTIM0ZgrXU>`e@M%l-1qET3k(wLgV>T%YqXLM^cg~Dt= zpK(`M;d7;T#i1Pr^Q%~oAq$tGtrfjnpg-YqUI6GqEVnnI=NbTMJK3>TyFkf-(f`zx+Eu6?Ij}r+lHU~=`NBEzt;2UA zCecpD`-Z-3T-J2lrVH7dW-6}T=0v)>RR9L~l|z-nia5y^fbL{!4`7Cf{#p9B-{`0% z)d$t5##3W)xS2AHmIy1*mq z=Jo?04uN$6&R1i8rImU=_`+vSzO}e)y?lFnXfvXSpbvknPXS&u2EHgYMR?RLdDQK# z@4$4fP;AT zcWRXID3Q@m9Z&Jr-j=E@`fiLL&nO}kmgPO5b<*^Dha(2!pTx^l1(^$i5oyHVWnfY1 zLUsSJ(%Ed+TVX(9WVCHALuY6ZsjK;@B*5r#KIqzL^Y))BuX9ot>Hb*f-Woyb!j6Og z0V$y6pB`>#h?_7=a1TdHs7Y|@Vsh#=XMUWZHmFw!>Fw$!nD5o_n&W$;IYK+Y!=7-^ zdT7g2?}fw(`bE6O`LP1OkHcT=nK%h!X^Ave#ysSF-tP4DK?cp)XQDubYdo}vI7I)s}MB!02H^RZz3B13pD;b$n2l8Vg z(IEf;4;)65bH6=x9C&YaHu+CVGoa3jW;S+af+B@%TowXC^bFT9aO|E9|GVVwXWgY`kEwu22WzI?7%2sF%qPVBtWHp`E@dVeA zd-NE{7>@-mymDFrV>IfR-0{9#N{(lTxrlLO`$@RhzyVB#cXbJfUkrVP`^=kEWR#PS zx1W6W74F&Qm!E;J*8jB%TddoCFg@d9=-E^NPxBdnd$1#0UV4fQ%CL ziz1bn!>6w2sDV2c+c@&?p(68jw`g1*JfxMGU9IoKh4zL%!IgEarh9JdV%(JUcTazB zmB?ldg_CXE!i#xt{!}qj=r?owG_>QCipn`%cN zq@&kOcpGbj%u^ovp#CtsJkzZG%Cqdk`mRgP=mF6!AomC%UVD1z$mtdp2c1-`zxo)c zT6ypQ=CtKm#uY*&Ir8WS`TEs z5RJWeNYLyvr0~!usVhzQSq_|by?9>E=1ZIwOZQ`R12q4vi7#$A-!9=r*(T#w8=&q2 zT7Iq(9LQm&%IIXidu*w|S;p&@Ar206Py2ZB`mm78`RlbY@G!zN?sJazUgp#*!(<{3 zfqLliDV=!U^i9@jR9+L;(w#2CSi|+X=rIUHT#PddU@V7d^__Ps+WFj>yt1&>_a zOiYa2W-t?7wPil7g}^1Y;PVLEDq~KDERB9&P!&_a^a31_fa>BN5!hdtFU^T;u)3b zCtaD!a)20svg;7Mo{}ihn&twa>2*`T8yvCQK2^Xp7ZwuQmqX3w_Eye@`zb49*c&!N zIbA;|&$9VGec)AMhep5aM0aH?%Ym-@&{7|Msm@}jzW+0&H4khx46xh72{|D377i=X z8FYU_ZcBG<{|R(0!RbB7!_P$kWarRaH5`j*wVnWaUesQ^rZyHHPIwINlt^BzJ{6Ie z3Z3s0Y>4j4Rh9#x$3vsCc%AL9aWK9cD6U=ZX2VxC5bD;cb_>yU`mN>f`(q=NZ_`8WsrV;Z2=kbnevgsvWD1A0D8 zq-D(wfR}%9V2LMP70S+_yxX-nZJLE`u&gDC-7P-^Yt!YGut zutQhKd?!b8fQb>M z^;EXN6GpZ9C$3EOjf;o7hw+56bDdX~oYgRRapO!-K{zWfLTGIg2&$YS1}~;1%B9QV z{fk04CUF;mfx>E9l39wsl6_2Qg8_VxJmU7zx!v{VS6w#&tw5T=j<6n{P(y>u5$c$z zT?A-rbM~v~GGqt=biqe$hX4%BrQB!>*It+JDLVt`3rDIugc91eLx1YUmQ-wR4R!)h zl=GSIW@HGd_qj%99$e%)dn+O)RfwzG0XxEMcz1;WwYV9nD`Z0Q&_a&R6NV_N{a=u+ z$8KE8Mb7O`57d6&6IW&zLXZX}F@p+l8hPW!7VkKEHM2xY!+$_bON8^_FK~Gx_6RxQ zxKYaMV7vwbJ0Nb%Q^uJeiwY>>h^{@ee2cR(aF4 zVC9b>cIz3bz*;>mr;gf44`LtQjb)1)cY@b9QNQ{(oMmY4)E9*NGroAvR9^u*_7G&34&N7#E%4K6tVCYLilNNx9j7}8{Tb^5thd`VOYFZk~0DjL0Lp@k;M zO(n8^jE(>b_8qJxi)(TjzHH<<$JN@3vgc)dJFH+1E@YOmk>l(8Z3TZ*cKvGQebMC1 z0m%2@&j4)8*)((5ro)pQurq8wNXWKNOL)SyiC{dVcVyK_(i*%*ug1b#)s$2i0{FMM z!!dtTln7ry#r~oh0CU3i6^}j=I&;opy4&wV`e^%~jm0&O=8ji$kt*b(v<8Y~upUOg zkMWpTN{(i1M)69-5&2O!@rwgJi6tqUTlYHEz6+vq0x@GF>IxVE+IOfet+w5fASMWj zUtPV(PFd+pEU|kK3wn#`>y=|VjJGowqj`B(n^N^iSzT4^AI&eR2HI%v&DJlPK~Of^ zCnqB~vkzQHT&@__1LoYAh>VKG%YIjX3b{Z~Yo6&~&~x}be^UershOZ|GL&_;xmvqL zE25P0T!#aFnxEx9=^`pS^CHv^A^hcY90)9D{86*81UMF%^cglkdRR}jwDqa{q%`5-4iQ6I(|X{(DHn2 zXo!Io<%TY2LZKYFc&(-IsOZ0(PkIiCz-EcX9=*Ln0t80-%50~@F6nBZeW{dffy%1^ zBge4u(TaJaJIXVCxYUw!4Na_h|Gjw{oXN1)nW%pB!pL;^a4BW+r$)Zp5ByMp@y*$W zB?E~p0-%6SU={p}wC^i&4`Yk?elzVQb~5y>a%-;zvEf8X?0U4XBEq$ zfcA#{71Fl)-eyjV#(&MZt<&GXOVq;4>w|FzCKL#Z=71AbOf!Bg1{+5()<4xr#=;Ht zI03bv;@F~MlFZ6`)f0`Ll`aMQdETPw19Jn8K(-5kMSNE=N2h)acuU!2&L%b6 z&6rTnm(K&C&;WoqA@6FW+MsOKTiqXNS{?xW(Rox~h8e_6hAHC~XbD^&tMFmWTGu7`RuGuffDAm}JNYTU7|cxl1WoNQWQji-av(!C^@uo;&1 zz;2UkRD{*JjUKvPQyS(zoDs4l^({p-ui>EzF<&j|3ac$LX1#xTyLtD0XxkU%^SnPs zuV_5onhJDJEyl^)1Z}y)oF`~cleTRrQ9xUh&RCV}&oj}2<+=-_jTP=p*mQ7*6ypnRH|IY|rpx!CER#)1wv%7s@ zq=8*PtMFL37tw+DS{evoNagNRKZQIyZak})_myi-;;_ZxsBN}Dao_>K9~^|)QuOLl zmT&z*7cO?aRn)sib&6p*@CxC%==0H-2w-w#M`iqTn&Uov0nKqgzLVythF_pn{3yvj z^Rd$5QsPv7Gw*2KtwQxv&K0XN4#NPfk&4#ijF5B?g@Dd|A#T;YCKf{x1fMw$}gv literal 0 HcmV?d00001 diff --git a/public/static/brand/og-image.png b/public/static/brand/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..88e10a1641475c8085f6a687c17e74d33bc71ccb GIT binary patch literal 31835 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 YX+FScmROV literal 0 HcmV?d00001 diff --git a/public/static/brand/og-image.svg b/public/static/brand/og-image.svg new file mode 100644 index 0000000..110d277 --- /dev/null +++ b/public/static/brand/og-image.svg @@ -0,0 +1 @@ +bliish.spacea space for anyone \ No newline at end of file diff --git a/public/static/brand/og-screenshot.webp b/public/static/brand/og-screenshot.webp new file mode 100644 index 0000000000000000000000000000000000000000..1686bd61f443863eaeeeab2a4a49680815e8f626 GIT binary patch literal 63812 zcmZ^}b95zLyTu#Z=-9T69ot67=%{1cw$-t18y(xWopg+S`~AN6oHOni_wK(|jj_k7 zRaNtO)^E-#6=?|x{#OtXO>q$=4JB@3=D%Zm1n^&=v>=fFV1hr{Q>BVX3o6K3VFQE^ zBYxO!KI|B*4rP6Qu;xDZ0f27Bz%h>Bf+`Z=lWKKFHEYu`L0|K9)0B;5_JBPhfz}-JSZ)tA^Pk#G=Y2Y;w^9lST=#A)g=bGrC z_t7um@8tvUWMBQ>fXBV_zH9G-A3J|nu6W;j>SyEI*PHC81&sK7|7g8x{rg8hPaM4^vD& z7RUpJBkfTq%KFOwMI&E3jCD4oFLQwrJhH*{Aes9T0c7`(@v_b<3D5oa(3wkB@i-3f z&!kf)=!zkShhC9A2_u@QLOnzsN=2&|`mVcFYUj30e19so4xWhVtzAv|)MHNs1A8>z zS%LXMi_|=D(Wi|{ubWE`zcR4uBM6ZbzBQRrY5fVRJi|O~#3Bq^n?mx|?P>OIiC_0V z_U?;XC}BBNCfun+*%B6Q`id%0boj|Tz(J+UE1=RC5faI3yf^tJM{~VhY4C z-d2NzYQMlzDp}9AWf6%Dd?$9OOF3P`C0(aw>(;;?jq?cSR9M%|3c8^cs#KV(^}WbZ zfqFQ!-NK2WfS=Qo+7+^Y3HM@jm|u*d>ZyJ{yrAucp}`BU&Irm~d@8&Pl4J0KX~VRH zQLIx!qgM%`E>&i_KI8P)A$(9y-U}J?N{K?NR94L2Tk?11L#S>R^xm0%>>j|-F|{xL zY+$>ozy5fGo3YVU-^%law}3ZSC^rmfS{9${@TrLmz;GgQF5StV;|m8V(Gz`!-~yH2 zo)co`w{L$MMIrO??w5kWne{H+{J}SXx#ug&@6O24IRv!pS*^eN`l~`zS`$u+v75wT z_vR;dQ2XO#-z?KEGXE3Ij#dhVB^h2{Jpx6&J}V)qjXp81@oszVo}UOo4W(;~uEK-*^zog^FB%tn1sp@~b6xh|Jh$Y{MG{ zg+jVnVzT`$=Z$OYuxEjGh`0NSKFw&tea~6Sv{qOt)oB<Kz*1Kp7BGC&=74(M|~%NU*FUu(}V>9GS1WVT?i7d^uq@Ib@nt= zO%9+#mQiG=+&drPU}%&lY@kc4@eEt`YO(>}HkTqr9jtF$e{cEUF2d6V{GTpTj3Y^Rwd;sx z#PeVI0}c_!op!J3$i!G`_WTLzS}rjPrn~PI0JL4HCG7(DB0NGKjxMY473rj5o*lJY zGySSe%^5*CE>RG20LDD}v0a+Syg!^TeE*b{bz9NRf1CSXr3T%i21h_C7q;>Lorq@? zJQn|OtfJ0@v$?m>@;i_KIwM>EGi_jCuQsl4y#d#0Qm_7}IQj9Z_V&OzX3b-Iga^Bc zX%;YXaRn^yzB^y<%i#k;8|=#^XltItZg`U#w`2ekq_9xR74KVbLK|vp&|2Mg>#3U# zq_XkKE;-D&r%Di2xM1tHk~K!18@Ao!4{nQb-?wn$5kJ8Z=2a32-DejT^Y{T`mOWaC z;dP1QsEb}Ok&z3F^XtGhaOA&A4P?B6{8yUN#HVwSzsUE{F-gKJ_}6Kd`^uIzyfg5fXZpf+i5r_E%u} zl2FNU)rtYWeS@y(_}0yPL}^lBfmY|4FBr3u+IP;4BM&N|hz8}=SvD4k44 z8|sSmltwL9;+NZxjb9xF71=X$u?=>b4_mV2o&MNgO^3)MgG!t&qhAL3f09Q?wS6cr zn(?|O^s0bp+QnMpiNy1xS5>nmaU9olNc37!Y{uosO4pwi4PJC#KNP1LI_=tioCK&h z?AB(>>o-1$g$dqjUl8CORZEhu&uvK6Ma;x#BGviD0-5waVN~p!BzPy-w$PyPkccfy zwY*%2J;-r8|4_{fSN4kdkmGjgmC61cFL)u#UkY&5{n;M;pW!1z6w(31{6kM+6Gc>d zdLO+kn!%vlwRp(&K9i&ee?`G{X`)vtla-SBZ)f-znRxUq`?FMNolvY#%gk)S=9Yv} z-)g~HuWPehe9+kXY3$NA2FPvM?J!rhaDB5V3$I8#4~J>JY_v!b%%ST;f2FX{PrUW zH#Dk6K&mjiNv}E&7l^Eg@r(Fsr<`nl9hnEIf=EpNx0KmSh#i!B(tymS1f_u3-@ zV#dc6z2v0aW76en*rNIi*Fgym(33Ee$`oK~;s;%V$2akYY&x2Mixqq$WxPN!aYhFO0-E6dp`E*SXZM*Z2F)*>Q~4L1`T zyg!G%vhYYg>EG0l2saT=`S&a@5o@pyzrI9QF^jgL_QPC1tF!$CZVKf=EFU9CYElMT zVTp=Uh8(phC>K>U>sd%BLk@9#m*p)kxqZz*%?$f-whSKS$Lpy=hKgmwPz29B>i|J{ z^hU6w*mw+Th_&1MOvpMq#--Ads~nRZ6{76aL@n+vY4=ZnX?W$GWYRvmyqPNF>`w{i zkB$IRqxTT)!Oeg9%}MNEFwEq?$E-J9SQ)h*X)90v9jq*xUt_|1-NWw!wcpAMQ0BXF7QwH{Ft6SPkUlPEWl1V$M>7Xt)uuZVa41c~rczbhl>nqOYSPJ@ z`-xxidisK)+oozgw)oRU)(L9MR@7MKbjPhc42rr2NL(iVh4a4;bK34dH2xQ^0NwP( ze>T}8&!_AZIjfN)#Yah|Gq@V*qAMxof7q&qH%E`|rgD918sK=>8`@Q|BDky(Oo4g1 zbRXdt-j4=9B7NEQOKcBGgkHZ9XQ6q;<84#Ekgjf)FRz^;4ItUpcx+d;f2}=el9YomxhdMROl?#M_}QN z2-2ccfii*4aEh9yfgC+Y3`qTttMK?-`)8B??{0=o?t2dLa-t=lZ+$_$1KKXKGv7o# z#&sF$2Chp^^qtj`$B3AuW97^GNChIkusAEWnfUoIL$B^Q|Ct$rG0+d)XGo6zRM-j z*QL+oF@gGg#5^>mmrm_>-uO1Do<`Gx;Hx`qPG~aiW+W;OI=fDm**tv;tUuS2T?1&0 zJA#JR8G?Eqr5Eu%4s}wQ^#PevFJQyDtm3Bp*E?0sAXPRQRc=iPX}mP|ur@b~*soa2 z_Rr`Jz5l?bsLQ`-a2&e=UueaFfAYiwAopifn0_wy)EVusx6Dp(K&Vw0Bt`wE9zd&@ z$x8k^lz=5*^HFhM57vIIk6!=eX=?&(| zKFo5Gl1u&xmzi1dIs;~10FknhCi+B=BAiU_I`#oqV71)?Td~EiRetIrkG}~hAnoa2 zeEmyW?EiHne`{tI`q$@h4H#(;$BF#>pjgv`v!|J7ra8ROc@0S`ge#lmUt^W|ayrcb zq?T^i%{VbJc||RTNjWw&EIDd@Y1O#oVB5-JsVg_4z<%5GSjeFvc=1{bRlC(IpgrdK zrnYHP$8t~7Pt5f{)+aoxq4s}jh)?(`B;m6(-kHJ$YlK+5)z-7T1}q-eRGKn>a{xlD zWz#w)-6ZHN(N9g3Z*I|Wa?XZ9;3FZPzfL?r#pJQZMKqFt;Gu)=7;7~Mjn!MfvWG@A zCWo!LWEMQ;;Q079I!;cn$2Jj_gAR4b0-*oLjsyK&4`mp`ZCk{=S=s`7Vx-N26@1MfQ&>H>Fo@LX zYzwV34REy{hYhQCG-j=?a}?X|I6b`PaKeLXWRQ4;Pt$^pJcS z7n`Hws|}H)i+1az->~S0VIv1X5T~MMbpz1cMEkl z_2EX3{T?LZd;bb84amxX2K=~vY+?JX)G&&s`@F1E{@7>J2!WSrD*%V3i$ZFP8K)S1 ze^4EHUyeThWLw>~=Dhslqs%)7QsMJ?$qsDa+P);wp5<|OPaCbXU1;CftY9A?$%&D0``2{cQGu%{2L zS2?{&E|TnKZTcDCC{^v7LzB2cs%KY8RXB87_}h+pvT5T=8H-d5AxfUnF|=D-ccxCq z(=y(M7_07gF`*I789YSK5YKPHAoYW!a8^@3&9}@!Q0_Nzz$US751O;?U$SGuxh$ zA3GJy9ouVeVQ;A*pbmUMo=k5LqeH|IEDfotD88}Lbf9-T>#OO(RqB4ZPKg_D+ImCu z&h;Q%6g5!@-@5dNh(RDNs>+JJ;>$z?4+;BSVMr|%a7nuKqa^v(BjcE=3c1BCG$qTX zDRT<5zy5>ae>!;X*8gGK-CDr&;=c5SE=0Zqw{P(`4aL!CEW3Liz8=w62E~&{R;kiw7;Z6CdMq=Dg5u#6?y%BYYWbC zG0ASl%4txSQEefP!<@Jk<(j}!;jj{*VEQ`y)!pSKmB`F7cDg84bLD{DT?YD=TL-3|cwb;A8<8XrzM9qK9$CJ0_xWo0c1!ix6F=E8 zMp7ZyVG{E@<%KE=Lz-pPHo>p{AdS^$__HqRzxjq6W`Y(=%|CYXpQV~n9_jBM8*wCU z;3rAT=*#5(P)ex9bVhGA3s%MS0|v*ez7Y;UVeO$hY;#ox!CvJ5FH*cW{5K@|yUSdk z+iK(Cs0+i9x?yG>PBu%U>eZ)xe33REHgk;K^iI15ExlFU?Ejx($Nt|O;6FLbzrkWC zvU(oxp-nk+H%sR`nj`1)B8aL?p_1G^_Vomb?)v|2ClNi;efa~)V~;%C{^Vl+U;QTR ze>2KVn15LsBH@L0ST0K@$bMVbSYT^g0Zef9FJePPgdJ_^)*}9s_xxSQ=jlJ;O@*tdpaRvMar*O&_9i`Cy}1AZp2;THW6W zLgA<8e`DeQH+i1zG}4(MPn0nu!KN9*3b`rte@{Ldr!KR4y99*&6SuH|Nc<-Yv~H`4 z&I1AYde}!6&yX^`(K=9q(y8B~H@2SyHmXdt`0)Ep&=Y||u$2_2*}S;}Q?&%aV1CHU zGOmpf1HwMOwf`2HihEE72i|SiYASrLk9XMdn!NN^R}=|@Ht8}=&pp#ynQhW*=fD)2 z*>)hXt_h;y@)Q+at2ttag$mTtl{>oZ^|P7v<7SftTRNu3d?b1S;}w4A8+K8NbP2DM z9a%a$I(sqwOsnTr11H2#COXC!Q_jU2NfVJug8g-Md)9BnVDh9Wim)&sd;+Mern*xv+eJ#Z356CdJjX~aWuu=!F9d?jfAU7eKuhB> zo5!$(Km`2AvUh2k0^a{jmP(*ZFWNzVpVpoE;ip zd~k`L_UR-sQ%9+!hVAHU5(*U=Scg5?{pf?8Y@=Jy*KTnVBt`i^G{x$)gZ`c6)2wtX zq@W)o7)M`RA)+qbf?83V>o;^nLqaX2y}2*q&Ece{D4n2sC-Gx zTD-~zvQw;~BxN)6>9(bS3fK4dV5e76IMos!M02Dd0fNT`fgK-BJ-DlfxkY3}f5H^G zMSMQ_TJ(lFz2YjP&TiB=>}-}rg3zmp089-}^2ZcQszE}0&v5kT0|B;1QS@J{$N5#n z>MTZR@xoTFU?{Ql%mu>Eo=Me^Btt(Oo>A{uZu41}QjOT1@ENpbbB$_JjVH6d){wxt zog$b+1xrxa5!00{uI(^1r8Fh^X_1heR4jno2|{!HJ4lKB2usm@WGBPz*3hv&&}qf8 zqb3yb4sr+cDWb4{{GH>zyXAC2-gpxu{?TvZv^%fXgWK^(Lrd(=11y%U`a$)8@AIF) zg@roFc!pfz9cNV2bgumZ8Iz3c5lOePWYCRzM}{iQc!QnVt~hz zJC9!`QTK2PFVQf-yf`&B{KowDSSDLDCbnfR)N(&Q$S4pni4eo-YuNXqLg z@fc-FqXz!4;`_Udw#WAl?@S$6tb9=wDmPO5P24{|vM#pco??t)Tm{9;i;SL}p)&G* z=sAOlwTcccI1=1f^2sDO;i_&$r9oVv61#S~tK!%v+xx{vGpg3ngIb^G2YVv`m{RU;W#7DYsC$k%KOPx8EWu{T4ANw?l5tKJ5FfaaktH z_3p?3mL=(8ZH$40!0!Fpjq8nLJiJzawzM50mh$tv6;IxN#ZJ?q?H*lMHCJ|)Fqelg z>-aOZQ5O`C>y#3y9|e>MgRE3lib(>_t<`o>OC%s{O$bXFw!VfaD$arXF>||ruJ8n)5WqvilKp##T}k~J1w>2KPmaa37V<^A>&7I*PUf< ziN|>Y{s*KKMJu%cWCesRdlydAV?Ysh_sxr}%63y=t@Edt=r(T`mGr|1k*=wO|sO4Y_+7)F-n&knfkrJD;^DPIp2|=lL z{U^7)(hs|rgs^cbtF02uOW`p|ElgoyY$0QeHl~X#BA_KFCQ&0#qzRuN`1_8md8kDn zB;{SbpY=*>GizBW@^W#F#XZbevUvo}`A29*ZpeUb8B z5$VQ=R)}T~+Kxw_DrZ87y4`kBsTvIH&Ka^m=WREAZS%56laXHXS5igY!ac;f)e zrSlr;tZ2_!Rp7)V7`Rj`k(6ia&q$@Nb~WoRT4S_eW300pAQRVknF%t8GV++U5Sz;= zEcdO@AEM;>4+ZEPTcm5y9Ig|On?To}J+Xe@QDV4eoS4sl3O2G-J!1GYyYSZcA^r)T z_?a_u1lxr|=L6NE%P67c{_k*~@B<;1Qkm7#3u zl&qxEb99f%(+S%=&`T?^2f~%0&P(_jnlfMBl^!lJPr8Cilyn!Nkz|%F`l3|XTCk2L z_|l`7FT-5v=I|6n>Q)HA-nIPu5+eF%@?q71p?R+YH|A3Id(Xk?`ZPra)}k}3 znRruII@0~Pk%U>lk;Un88tB&h@XAc4f1re?mqhmmdAwFN;dN}lfnvdMxD15H6U}VO|l6ri46@G6aWfy zF$mlCrx4J3?sD@+?K>`6Aw^eySQq|1=E-wU!>V^&C1+UEXW#bg9?|nTN`8JH6s6#0le6wGa!R!=55#}k`aUWDWdLwN3a0-$qAX%0 z=g6_<#0cw|LW3s&jU6N1lBOInQ%184Kt}+Qy)qxV$I_3gE|sNu)0`6va0C^umuq}O zyrgANe$#ESsh4P*mguy(rN!c){6RbMeLDwxR+VAU4?bw|1O7m*h9>kVoeb^xCvo}N zxNKw_$zGCpsgorRB;?#YW_kCxWxp}|IA=Ai4zRBp9H0cd4ELkU=DS!AYgathl09vo zq7_4;p^uIg8+M@tgpY6SkD8O=?4{mFU#z?ahoc+Op-kV`=L@hcRqwe<-_dl`x25|x z3daj+KV+K*f40iS6EG$m!wdePBYHX(D=E-ysAx@;2qeNilD6OVZ&qy5PXz~3-n15d!l_2F{(cX@=I@61 zCOrePONq9kx3@vpO9no-usCDiq7ODa6*XUOl%m%;9$rWd_3e7QQ~Wz&D7a3s@iW|c z=a-tz(=kyv(8Uoxj7aVdwCs*4tsRa&eW#R7$9w@&)^!NlpqbE) zPPi@HL{doe0lyI@-6ha-Z0`>k73?%33&0@Z!9}lSSUFH$3N_RycMcs+LZIn_5JuEK zp5$S4WkM-oklsyw6bDs4iJ|3pjjL zojrzpZuUF795k3d75zIHIh6&Cj6H^Gez(I6BE>sil};%qnk$~aDP>UGRT$|L=h5-n zIU98fnF%>Gk3U$KLHs}f*LUf2gRZHnw*wl_pQviZipIO@6`<>0r}0k#G&c|JQPbbs zZ%ab*VEn%t9FwK~CcdS&>~XB=?+CL7kA7J0RHypg+oMoZM}ZuF!@`uvthihD%IN3{xX_09bf zy&kJKHLc7>ymAjE4CB5ZzZYgq@m(~1@4oG)fHp1OGk~=wYJJt87T`}I4tR+XB3r|r z6pdJ#+}{x;A@o-2C+ZU(Sb($cnW0gPvcw#lElH}+c+25rwN(E@eutuTvL3cD8~xse zs)ZpUR)`R~9hy!QWhC0Kd3T1s!7hz%NtAlilUjZrG_FiB8h1e6dmu0c&RI~ogUl&37@ABZyEw(VgzpJL7GV7x6TC79NO?jR22bnkgXkz4?|71)LE<7 zYrIZC&}`hC{enVU`V-6T6uu4EF0-`!@GY1Be2WY$-nw0n6^Dt`&O$WMS0ULBLP(Ee zW~oCdw^r~nlaf_-JiZlI-l1OeP^;*!o4!ICwDn$P#nAde+=aWHvkIF2TLXXXLil>q z1V=|m!}T9Q2IJ8io~U6tJ8a~8=g;WA4xHvG4vm>q$Mq&`g`45-b)yFu zrChqZPMUO@AqxDCZA`K}H3q0O{Uv1z-{7Ov16$$JtJ~^C4G`kxEO_7S(I5*dZ3el2`n3%;}{;Ra7fLx1wpbz!}T^a!kZ}m87>~<1u4t zpu)>8pF{VIZJ5nqSJ>5eyjvp1n?*GMv&8Otr)?40(J#H*>TqnV>cGejY}dE~#PsbT zjMXe!7G+J;W^~nFixzK|p6;bpGiwI1fjCC^f`UB}VZJjYw=mUKyy4Y+qS#uh(3)(As;m>@p!q6LBG>!sGRakB83oVCGs#6M^BHYwiTr$+!)qhF2kYem)J@w ztoBO5;d{p7M$3zuW`JT*I@=m#DCr^Y(}BjB9Dke!oH!E7jpfh9Z}`=kPmNc7OtLqe zmWKQ5us&>s^=rso5M7bO>>};gceK_PqvS@MODS7%tF^;4u6Sk6!kyFZRLj4Y%+xRN z((XXzrmcvZ$;Xb85@Z=X3h_G=*rF^iH|s@n%vnz>#QlDD5xm1AbPnM1eHZugbM^P> zkdFhBma_x>Q)k^<1D4JAkl%ohQ@!~=XK^t5(}w!f@gSj`o#yVf{h%^Tb*Tgod( zlJ0VFPqiOr9R!xx=0@bOTm&Fo$gy+xp%ufH9{>S-$nQJPQ#I#aZk+EjGn=gfIg@AU zmp`7L!q~y;dnYI3Er(~GDO)$!CvtO2*mcVQ`bjdH!&~aQoE@PP&llK_{!ONc1d>je z4G{7_e&Vr|ioWqB!7tC7(y2p53L2K+Crg8k(gxZmwA!NmbIF;^K5Rl<13nyTVHlCJszr zk=sxn+aE2#PmTCx+fY>cCIlfJ*UXy^sUDC%Lll1?(lmGqKFy4IsQ(x&Dvka?n}^bb zocAQyYVQ@y;1;agxKV0tEr8M(SY|j^WVZfe^^-d#y#d`Vj+laly$Ip$BBjNW?v`JaytFo1yRcA(IO1Y$Sp;i>F4b zCj`-)RJUbevKA>6wQ(L|DsjNwCP411*(<@}hEuCh*`vQb<;l18uW@Qf5U_Xgs~?@K zk{uRq4f5aHl=9hinc0&jxD`OF3FIr(53m;B?Kr2zAn@eSLQ)?}lVm1Z@ya_qsC*Fr z*vUp;;i)GrVy4KL3PAWd+fnLINh4h*S;v-?RkAFx*O>-R?0A^Czf`+Z1DF>ziA&|% zy)BgdtRf7-Qrn80bkdg=M3Z=(2#!DF%2rz{jBi)#t-)O8#6-DDzCnRKTY#{khiDyxi9EsLPbE zGKT^k3JhAs2GIgE(hm(X`?JhoD2nl~+thzK9FR-1R>ujZiD$#FQ^n?m+j(uls*jbH zTEZoyR%7&)Z!w`EIK`c#E~uj$!uG@!3K{oKj$)I1$flZIyVtVsk~cX0Q9jHh;i2Rk z1{Gu+MLMlKyX_ywt6`Hu>|fTN2Z7lV_EiZk7tj2}*YHn1U3owWWt zDRgiiZ;X(QmO#Rth3rFh?5{(_(V;RZ#dK9RXjo#L>1$~7a9W`#??gi@X;U!n%p;gfK z1rp8f8q*T8!*GB&RMb~tJ#N`s0=~2jbexWSQN6+}uOnt1?fy4>*6u3%O);os7lkw- zSIa9X)(g8nhqjNz^?7@zafVjV$yf5Z5=J(-mB=h9MV_t*SKemFe$U_+ z{nV(W`#B(nbl=O6v~bSr^)hfS8N;qY#3q>7(F(dw{wuQh{uV>~BfgbPt`U8%n`$|}*VUEv?)wkwc^M3mJNg6MkE z0^+GX6wZdo(6J_~1&U$Q*3ADQQwS*WTp=|rRBLHJ#n%G3U?Y!>3> zO#4UGcebzQ)1d5#_<%K+W>oOU_tzqdx@2qcY^Z#9R$s(aJ#2KmT~Y`yvKdv&_VjR* z6KsrZDvYmsHIO4bT7!`Sp&jlgN1TbB0K^WlxU^5|h30T*5)5=h8Tv~LCRVMOdmtSt ziWVl1SeD)9&=*pY4n;=NC5jH9++w+hNQRp>0qMf1%mP=8Rm`lx(*Q>eVbg2Ejg^gm z8$GN~9U#_4Ew5QaG9j(i?9u&AzcxMQp3qJmirvFh^s?!xlfvgdM=;ku6`JiinJ~h9$BeJhWh6RI!X;gCwS`h$xD@>#@HR@BM^ znp&&Pu3@NXc#0yrXBpO#_b{WjTxA$iaH_||n*s`BBwn~uQ|jKRY~(xHM{F@hJG!e+ zkCA=ac8ifJC<58_UNO8z4ddAG6&?UhCXP{wb(x5kvoGxIFnx9>yYxh6?d0Hz4W4 z1mt}6m%vKyy$N(Qq* z>E!pm1vD8T->I#KCmf!BueTl9oh$RpYiv%8ZE)cKv=1L1YGDhHF?pRfe74>_CmODF zeLb5wv1*w>kL4%KeYr%=c&If1#qThpW^fI(rO;71>>Rft2T{o7V>Mm5Re8|3a~7am zz292^ai>{ueG6Ml2+xh{h+d!y!dYvLH5rN;$bxvX!xkq%kYhx&`w=%K5tPMFc`iMUgwtN`r53
;0m(Vq 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�HsOai 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 idAnchor("blog", target), + comment: (target: IdTarget) => idAnchor("comment", target), + comments: "comments", + groupPosts: "group-posts", + post: (target: IdTarget) => idAnchor("post", target), + profileActions: "profile-actions", + wall: "wall" +} as const; diff --git a/src/auditLabels.ts b/src/auditLabels.ts new file mode 100644 index 0000000..77b8e75 --- /dev/null +++ b/src/auditLabels.ts @@ -0,0 +1,62 @@ +const auditActionLabels: Record = { + ban: "Banned", + create: "Created", + delete: "Deleted", + disable: "Disabled", + disable_raid_mode: "Disabled raid mode", + enable: "Enabled", + enable_raid_mode: "Enabled raid mode", + password: "Changed password", + queue: "Queued", + reset: "Reset", + reset_color_theme: "Reset color theme", + resolve: "Resolved", + role: "Changed role", + unban: "Unbanned", + update: "Updated", + update_color_theme: "Updated color theme", + update_site_contact: "Updated contact settings", + update_site_home: "Updated home page", + update_site_identity: "Updated site identity", + verify: "Verified" +}; + +const auditSubjectTypeLabels = { + app_setting: "Site setting", + automod_rule: "Automod rule", + blog: "Blog entry", + blog_comment: "Blog comment", + email: "Email", + favorite: "Favorite", + group: "Group", + message: "Private message", + post: "Post", + post_comment: "Post comment", + rate_limit: "Rate limits", + report: "Report", + skin: "Skin", + skin_comment: "Skin comment", + user: "Profile" +} as const; + +export type AuditSubjectType = keyof typeof auditSubjectTypeLabels; + +export function auditActionLabel(action: string) { + return auditActionLabels[action] ?? readableToken(action); +} + +export function subjectTypeLabel(type: string) { + return isAuditSubjectType(type) ? auditSubjectTypeLabels[type] : readableToken(type); +} + +export function isAuditSubjectType(type: string): type is AuditSubjectType { + return Object.hasOwn(auditSubjectTypeLabels, type); +} + +function readableToken(value: string) { + return value + .split(/[._-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") || value; +} diff --git a/src/automodPolicy.ts b/src/automodPolicy.ts new file mode 100644 index 0000000..ee5257b --- /dev/null +++ b/src/automodPolicy.ts @@ -0,0 +1,26 @@ +export const automodScopes = ["all", "profile", "blog", "post", "comment", "group", "skin", "message"] as const; +export type AutomodScope = (typeof automodScopes)[number]; +const automodScopeSet = new Set(automodScopes); + +export const automodPatternTypes = ["keyword", "regex"] as const; +export type AutomodPatternType = (typeof automodPatternTypes)[number]; +const automodPatternTypeSet = new Set(automodPatternTypes); + +export const automodActions = ["review", "reject"] as const; +export type AutomodAction = (typeof automodActions)[number]; +const automodActionSet = new Set(automodActions); + +export const automodPatternMax = 5_000; +export const automodScanMax = 10_000; + +export function isAutomodScope(value: string): value is AutomodScope { + return automodScopeSet.has(value); +} + +export function isAutomodPatternType(value: string): value is AutomodPatternType { + return automodPatternTypeSet.has(value); +} + +export function isAutomodAction(value: string): value is AutomodAction { + return automodActionSet.has(value); +} diff --git a/src/brand.ts b/src/brand.ts new file mode 100644 index 0000000..30cfe84 --- /dev/null +++ b/src/brand.ts @@ -0,0 +1,10 @@ +export const brandIconShapeSvg = [ + '', + '' +].join(""); + +export const brandIconSvg = [ + '', + brandIconShapeSvg, + "" +].join(""); diff --git a/src/currentUser.ts b/src/currentUser.ts new file mode 100644 index 0000000..63d3a23 --- /dev/null +++ b/src/currentUser.ts @@ -0,0 +1,11 @@ +import type { UserRole } from "./roles.js"; + +export type CurrentUser = { + id: number; + username: string; + email: string; + role: UserRole; + timeZone: string; + verifiedAt?: string | null; + bannedAt?: string | null; +}; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..62f4c99 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,65 @@ +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { HTTPException } from "hono/http-exception"; +import { routeRegistrars } from "./routes/index.js"; +import { registerSystemRoutes } from "./routes/system/index.js"; +import { registerMediaRoutes } from "./routes/system/media.js"; +import { loadSession } from "./server/auth/session.js"; +import { initializeDatabase } from "./server/db/schema.js"; +import { siteSettings } from "./server/db/siteSettings.js"; +import { env } from "./server/env.js"; +import { statusTitle } from "./server/http.js"; +import { blockedCrawlerMiddleware } from "./server/indexing/crawlers.js"; +import { indexingMiddleware } from "./server/indexing/middleware.js"; +import { limits } from "./policy.js"; +import { plainPage } from "./server/render.js"; +import { securityHeaders } from "./server/security/headers.js"; +import type { AppBindings } from "./server/context.js"; + +initializeDatabase(); + +const app = new Hono(); + +app.use(async (c, next) => { + try { + await next(); + } finally { + for (const [name, value] of Object.entries(securityHeaders)) c.header(name, value); + } +}); +app.use( + bodyLimit({ + maxSize: limits.requestBytes, + onError: () => { + throw new HTTPException(413, { message: "Request body is too large." }); + } + }) +); + +app.use(blockedCrawlerMiddleware()); +app.use("/static/*", serveStatic({ root: "./public" })); +registerSystemRoutes(app); +app.use(loadSession); +app.use(indexingMiddleware()); +registerMediaRoutes(app); + +app.onError((error, c) => { + if (error instanceof HTTPException) { + const response = error.getResponse(); + if (response.status >= 300 && response.status < 400) return response; + return plainPage(c, statusTitle(response.status), error.message || "The request could not be completed.", response.status); + } + console.error(error); + return plainPage(c, "Something went wrong", "The server could not complete the request.", 500); +}); +app.notFound((c) => plainPage(c, "Not found", "The page does not exist.", 404)); + +for (const registerRoutes of routeRegistrars) { + registerRoutes(app); +} + +serve({ fetch: app.fetch, port: env.port, hostname: env.host }, (info) => { + console.log(`${siteSettings().identity.name} listening on http://${info.address}:${info.port}`); +}); diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..86a3d61 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,3 @@ +export const messageFormContexts = { + conversation: "conversation" +} as const; diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..73b34db --- /dev/null +++ b/src/models.ts @@ -0,0 +1,304 @@ +import type { AutomodAction, AutomodPatternType, AutomodScope } from "./automodPolicy.js"; +import type { AuditSubjectType } from "./auditLabels.js"; +import type { CurrentUser } from "./currentUser.js"; +import type { NotificationContextType, NotificationKind, NotificationSubjectType } from "./notifications.js"; +import type { BlogCategory, friendshipStatus, RateLimitAction, ReportSubjectType } from "./policy.js"; +import type { SocialLinks } from "./socialLinks.js"; + +export type { AutomodAction, AutomodPatternType, AutomodScope } from "./automodPolicy.js"; + +export const defaultInterestNames = ["General", "Music", "Movies", "Television", "Books", "Heroes"] as const; + +export const defaultInterests = { + General: "", + Music: "", + Movies: "", + Television: "", + Books: "", + Heroes: "" +} satisfies Record<(typeof defaultInterestNames)[number], string>; + +export const defaultStatus = { + status: "", + currentVibe: "" +}; + +export type UserProfile = CurrentUser & { + createdAt: string; + handle: string; + bioHtml: string; + skinHtml: string; + interests: typeof defaultInterests; + socialLinks: SocialLinks; + status: typeof defaultStatus; + pfp: string; + themeSong: string; + currentGroupId: number | null; + private: boolean; + views: number; +}; + +export type PersonCard = { + id: number; + username: string; + handle: string; + pfp: string; +}; + +export type CommentItem = { + id: number; + textHtml: string; + createdAt: string; + authorId: number; + authorRole: string; + authorHandle: string; + authorSkinHtml: string; + username: string; + pfp: string; + parentId: number | null; +}; + +export type Friendship = { + id: number; + sender_id: number; + receiver_id: number; + status: (typeof friendshipStatus)[keyof typeof friendshipStatus]; +}; + +export type BlogItem = { + id: number; + title: string; + bodyHtml: string; + createdAt: string; + updatedAt: string; + authorId: number; + authorRole: string; + authorHandle: string; + username: string; + category: BlogCategory; + privacyLevel: number; + pinned: number; + propsCount: number; + commentCount: number; + proppedByViewer: number; + commentsEnabled: number; +}; + +export type BlogPreview = Pick; +export type BlogListItem = BlogPreview & Partial>; +export type StaffUserRow = CurrentUser & { + createdAt: string; + handle: string; + pfp: string; + views: number; + verifiedAt: string | null; + bannedAt: string | null; +}; + +export type GroupItem = { + id: number; + name: string; + descriptionHtml: string; + createdAt: string; + ownerId: number; + ownerRole: string; + ownerHandle: string; + ownerName: string; + memberCount: number; +}; + +export type GroupMember = PersonCard & { + role: string; + joinedAt: string; +}; + +export type PostCommentBumpActor = { + id: number; + handle: string; + name: string; +}; + +export type PostCommentBump = { + commentedAt: string; + commenterCount: number; + actors: PostCommentBumpActor[]; +}; + +export type PostItem = { + id: number; + bodyHtml: string; + mediaFilename: string | null; + createdAt: string; + updatedAt: string; + authorId: number; + authorRole: string; + authorHandle: string; + authorSkinHtml: string; + username: string; + pfp: string; + wallUserId: number | null; + wallUserHandle: string | null; + wallUsername: string | null; + groupId: number | null; + groupName: string | null; + groupOwnerId: number | null; + propCount: number; + commentCount: number; + proppedByViewer: number; + viewerCanInteract: number; + commentBump?: PostCommentBump | null; +}; + +export type ReportItem = { + id: number; + reporterId: number | null; + reporterName: string | null; + reporterHandle: string | null; + subjectAuthorId: number | null; + subjectAuthorName: string | null; + subjectAuthorHandle: string | null; + subjectType: ReportSubjectType; + subjectId: number; + subjectLabel: string; + subjectUrl: string | null; + subjectSummary: string; + subjectMissing: boolean; + subjectCanDelete: boolean; + reasonHtml: string; + createdAt: string; + resolvedAt: string | null; + resolvedByName: string | null; +}; + +export type MessageItem = { + id: number; + senderId: number; + senderName: string; + senderHandle: string; + senderPfp: string; + receiverId: number; + receiverName: string; + receiverHandle: string; + subject: string; + bodyHtml: string; + readAt: string | null; + createdAt: string; +}; + +export type MessageConversation = { + id: number; + otherUserId: number; + otherName: string; + otherHandle: string; + otherPfp: string; + latestSenderId: number; + latestSubject: string; + unreadCount: number; + createdAt: string; +}; + +export type NotificationItem = { + id: number; + recipientId: number; + actorId: number; + actorName: string; + actorHandle: string; + kind: NotificationKind; + subjectType: NotificationSubjectType; + subjectId: number; + contextType: NotificationContextType; + contextId: number; + contextPostAuthorId: number | null; + contextPostWallUserId: number | null; + contextTitle: string | null; + readAt: string | null; + createdAt: string; +}; + +export type SkinItem = { + id: number; + sourceKey: string | null; + title: string; + descriptionHtml: string; + codeHtml: string; + createdAt: string; + updatedAt: string; + authorId: number | null; + authorRole: string; + authorHandle: string; + username: string; + commentCount: number; +}; + +export type TableCount = { + name: string; + count: number; +}; + +export type FavoriteEdge = { + userId: number; + username: string; + userHandle: string; + favoriteId: number; + favoriteName: string; + favoriteHandle: string; + createdAt: string; +}; + +export type AuditItem = { + id: number; + actorId: number | null; + actorName: string | null; + actorHandle: string | null; + action: string; + subjectType: AuditSubjectType; + subjectId: number; + subjectLabel: string; + subjectUrl: string | null; + subjectSummary: string; + subjectMissing: boolean; + subjectAuthorId: number | null; + subjectAuthorName: string | null; + subjectAuthorHandle: string | null; + reasonHtml: string; + metadataJson: string; + createdAt: string; +}; + +export type AutomodRule = { + id: number; + name: string; + pattern: string; + patternType: AutomodPatternType; + scope: AutomodScope; + action: AutomodAction; + enabled: number; + createdBy: number | null; + createdByName: string | null; + createdAt: string; + updatedAt: string; +}; + +export type AutomodMatch = { + rule: AutomodRule; +}; + +export type EmailOutboxItem = { + id: number; + toEmail: string; + subject: string; + bodyText: string; + sentAt: string | null; + deliveryError: string | null; + createdAt: string; +}; + +export type RateLimitSetting = { + action: RateLimitAction; + limit: number; + windowSeconds: number; + defaultLimit: number; + defaultWindowSeconds: number; + overridden: boolean; + updatedAt: string | null; +}; diff --git a/src/navigation.ts b/src/navigation.ts new file mode 100644 index 0000000..3d85eae --- /dev/null +++ b/src/navigation.ts @@ -0,0 +1,33 @@ +import { messagesPath } from "./paths.js"; + +export const pageLinks = { + about: { label: "About", href: "/about" }, + adminUsers: { label: "User list", href: "/admin/users" }, + blog: { label: "Blog", href: "/blog" }, + browse: { label: "Browse", href: "/browse" }, + favorites: { label: "Favs", href: "/favorites" }, + feed: { label: "Feed", href: "/feed" }, + groups: { label: "Groups", href: "/groups" }, + home: { label: "Home", href: "/" }, + messages: { label: "Messages", href: messagesPath }, + props: { label: "Props", href: "/props" }, + search: { label: "Search", href: "/search" }, + settings: { label: "Account settings", href: "/settings" }, + skins: { label: "Skins", href: "/skins" } +} as const; + +export type PageLinkKey = keyof typeof pageLinks; + +export const mainNavPageOrder = [ + "home", + "feed", + "messages", + "groups", + "browse", + "search", + "blog", + "skins", + "favorites", + "props", + "about" +] as const satisfies readonly PageLinkKey[]; diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..5441b37 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,75 @@ +export const notificationKinds = { + blogComment: "blog_comment", + blogCommentFollowed: "blog_comment_followed", + blogCommentReply: "blog_comment_reply", + blogProp: "blog_prop", + favorite: "favorite", + friendAccepted: "friend_accepted", + postComment: "post_comment", + postCommentFollowed: "post_comment_followed", + postCommentReply: "post_comment_reply", + postProp: "post_prop", + skinComment: "skin_comment", + skinCommentFollowed: "skin_comment_followed", + skinCommentReply: "skin_comment_reply", + wallPost: "wall_post" +} as const; + +export const notificationKindNames = Object.values(notificationKinds); +export type NotificationKind = (typeof notificationKindNames)[number]; + +export const notificationPreferenceTypes = { + // Stored as wall_comments for compatibility with existing preference rows. + comments: "wall_comments", + favorites: "favorites", + friendAccepts: "friend_accepts", + props: "props", + wallPosts: "wall_posts" +} as const; + +export const notificationPreferenceTypeNames = Object.values(notificationPreferenceTypes); +export type NotificationPreferenceType = (typeof notificationPreferenceTypeNames)[number]; +export type NotificationPreferences = Record; + +export const commentNotificationKinds: ReadonlySet = new Set([ + notificationKinds.blogComment, + notificationKinds.blogCommentFollowed, + notificationKinds.blogCommentReply, + notificationKinds.postComment, + notificationKinds.postCommentFollowed, + notificationKinds.postCommentReply, + notificationKinds.skinComment, + notificationKinds.skinCommentFollowed, + notificationKinds.skinCommentReply +]); + +export const notificationTextByKind = { + [notificationKinds.blogComment]: "commented on your blog entry.", + [notificationKinds.blogCommentFollowed]: "commented on a blog entry you commented on.", + [notificationKinds.blogCommentReply]: "replied to your blog comment.", + [notificationKinds.blogProp]: "gave props to your blog entry.", + [notificationKinds.favorite]: "added you to favorites.", + [notificationKinds.friendAccepted]: "accepted your friend request.", + [notificationKinds.postComment]: "commented on your post.", + [notificationKinds.postCommentFollowed]: "commented on a post you commented on.", + [notificationKinds.postCommentReply]: "replied to your comment.", + [notificationKinds.postProp]: "gave props to your post.", + [notificationKinds.skinComment]: "commented on your skin.", + [notificationKinds.skinCommentFollowed]: "commented on a skin you commented on.", + [notificationKinds.skinCommentReply]: "replied to your skin comment.", + [notificationKinds.wallPost]: "posted on your wall." +} satisfies Record; + +export const notificationSubjectTypeNames = [ + "blog", + "blog_comment", + "post", + "post_comment", + "skin", + "skin_comment", + "user" +] as const; +export type NotificationSubjectType = (typeof notificationSubjectTypeNames)[number]; + +export const notificationContextTypeNames = ["blog", "post", "skin", "user"] as const; +export type NotificationContextType = (typeof notificationContextTypeNames)[number]; diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..310a121 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,73 @@ +import type { ReportSubjectType } from "./policy.js"; + +type ProfileTarget = string | { handle: string }; +type IdTarget = number | { id: number }; +type NewMessagePathOptions = { + forward?: "profile"; + id?: number; + mode?: "instant"; + subject?: string; + to?: string; +}; + +export const conversationsBeforeParam = "conversationsBefore"; +export const messagesPath = "/messages"; +export const newMessagePathBase = `${messagesPath}/new`; +export const notificationsPath = "/notifications"; +export const reportPathBase = "/report"; + +function profileHandle(target: ProfileTarget) { + return typeof target === "string" ? target : target.handle; +} + +function idValue(target: IdTarget) { + return typeof target === "number" ? target : target.id; +} + +function segment(value: string | number) { + return encodeURIComponent(String(value)); +} + +export const profilePath = (target: ProfileTarget) => `/u/${segment(profileHandle(target))}`; +export const profileBlogPath = (target: ProfileTarget) => `${profilePath(target)}/blog`; +export const profileFriendsPath = (target: ProfileTarget) => `${profilePath(target)}/friends`; +export const profileWallPath = (target: ProfileTarget) => `${profilePath(target)}/wall`; +export const groupPath = (target: IdTarget) => `/g/${segment(idValue(target))}`; +export const blogPath = (target: IdTarget) => `/b/${segment(idValue(target))}`; +export const blogCommentsPath = (target: IdTarget) => `${blogPath(target)}/comments`; +export const postPath = (target: IdTarget) => `/p/${segment(idValue(target))}`; +export const postCommentsPath = (target: IdTarget) => `${postPath(target)}/comments`; +export const skinPath = (target: IdTarget) => `/s/${segment(idValue(target))}`; +export const skinCommentsPath = (target: IdTarget) => `${skinPath(target)}/comments`; + +export function reportPath(type: ReportSubjectType, target: IdTarget) { + const params = new URLSearchParams({ type, id: String(idValue(target)) }); + return `${reportPathBase}?${params.toString()}`; +} + +export function messageConversationPath(handle: string, conversationsBefore?: string | null) { + const params = new URLSearchParams({ with: handle }); + if (conversationsBefore) params.set(conversationsBeforeParam, conversationsBefore); + return `${messagesPath}?${params.toString()}`; +} + +export function messageDeletePath(target: IdTarget) { + return `${messagesPath}/${segment(idValue(target))}/delete`; +} + +export function newMessagePath(options: NewMessagePathOptions = {}) { + const params = new URLSearchParams(); + if (options.to) params.set("to", options.to); + if (options.forward) params.set("forward", options.forward); + if (options.id) params.set("id", String(options.id)); + if (options.mode) params.set("mode", options.mode); + if (options.subject) params.set("subject", options.subject); + const query = params.toString(); + return query ? `${newMessagePathBase}?${query}` : newMessagePathBase; +} + +const mediaPath = (bucket: string, filename: string) => `${bucket}/${encodeURIComponent(filename)}`; + +export const profileImagePath = (filename: string) => mediaPath("/media/pfp", filename); +export const postImagePath = (filename: string) => mediaPath("/media/post-images", filename); +export const themeSongPath = (filename: string) => mediaPath("/media/theme-songs", filename); diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..bbcee7d --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,229 @@ +export const defaultMedia = { + pfp: "default", + themeSong: "default.mp3" +}; + +export const systemIds = { + defaultGroupId: 1 +}; + +export const defaultProfileImageNames = new Set([defaultMedia.pfp]); + +export const database = { + busyTimeoutMs: 5000 +}; + +export const session = { + cookieName: "app_session", + csrfCookieName: "app_csrf", + tokenBytes: 36, + csrfTokenBytes: 24, + // 24h: long enough that an idle login or signup form still works when the + // user comes back to it. The anon CSRF cookie grants no authority, so a + // longer window is low impact. + anonCsrfMaxAgeSeconds: 60 * 60 * 24, + // 30 day persistent login. + maxAgeSeconds: 30 * 60 * 60 * 24 +}; + +export const themePreference = { + cookieName: "app_theme", + maxAgeSeconds: 60 * 60 * 24 * 365 +}; + +export const friendshipStatus = { + pending: "PENDING", + accepted: "ACCEPTED" +} as const; + +export const limits = { + requestBytes: 12 * 1024 * 1024, + uploadBytes: 10 * 1024 * 1024, + passwordMin: 8, + passwordMax: 256, + usernameMin: 2, + usernameMax: 50, + handleMin: 3, + handleMax: 40, + emailMax: 254, + shortText: 255, + siteName: 80, + siteTagline: 120, + siteAnnouncement: 280, + siteWelcomeText: 500, + contactAddress: 500, + searchQuery: 50, + searchResults: 10, + socialLinkUrl: 200, + groupText: 500, + postText: 2000, + interest: 500, + userText: 2000, + skinHtml: 20000, + contentBody: 20000, + listPage: 50, + newestPeople: 6, + newestCommunities: 3, + profileFrontRow: 8, + profileBlogPreview: 5, + profileWallPreview: 10, + groupPostPreview: 20, + feedPosts: 30, + commentsPage: 20, + exportRows: 1000 +}; + +export const rateLimits = { + pruneAfterSeconds: 60 * 60 * 48, + message: "You are doing that too quickly. Try again soon.", + actions: { + "auth.login": { limit: 20, windowSeconds: 10 * 60 }, + "auth.signup": { limit: 5, windowSeconds: 60 * 60 }, + "auth.reset": { limit: 5, windowSeconds: 60 * 60 }, + "account.write": { limit: 30, windowSeconds: 10 * 60 }, + "profile.write": { limit: 20, windowSeconds: 10 * 60 }, + "post.create": { limit: 5, windowSeconds: 10 * 60 }, + "blog.create": { limit: 3, windowSeconds: 60 * 60 }, + "skin.create": { limit: 3, windowSeconds: 60 * 60 }, + "group.create": { limit: 3, windowSeconds: 60 * 60 }, + "comment.create": { limit: 15, windowSeconds: 10 * 60 }, + "message.send": { limit: 10, windowSeconds: 10 * 60 }, + "notification.write": { limit: 120, windowSeconds: 10 * 60 }, + "relationship.write": { limit: 120, windowSeconds: 10 * 60 }, + "engagement.write": { limit: 300, windowSeconds: 10 * 60 }, + "content.write": { limit: 60, windowSeconds: 10 * 60 }, + "report.create": { limit: 10, windowSeconds: 60 * 60 }, + "staff.write": { limit: 240, windowSeconds: 10 * 60 } + } +} as const; + +export type RateLimitAction = keyof typeof rateLimits.actions; + +export const blogCategories = [ + "Art", + "Automotive", + "Fashion", + "Financial", + "Food", + "Games", + "Life", + "Literature", + "Math & science", + "Movies & TV", + "Music", + "Paranormal", + "Politics", + "Humanity", + "Romance", + "Sports", + "Technology", + "Travel" +] as const; + +export type BlogCategory = (typeof blogCategories)[number]; +export const defaultBlogCategory: BlogCategory = "Life"; + +const blogCategorySet = new Set(blogCategories); + +export function isBlogCategory(value: string): value is BlogCategory { + return blogCategorySet.has(value); +} + +export const uploadSizeLabel = `${limits.uploadBytes / 1024 / 1024}MB`; + +export const mediaAccept = { + image: "image/jpeg,image/png,image/gif,image/webp", + audio: "audio/mpeg" +}; + +export const reservedHandles: ReadonlySet = new Set([ + "abuse", + "admin", + "administrator", + "bliish", + "bliishspace", + "help", + "moderator", + "root", + "security", + "staff", + "support", + "system" +]); + +const characterCountFormatter = new Intl.NumberFormat("en-US"); + +function characterCountLabel(count: number) { + const unit = count === 1 ? "character" : "characters"; + return `${characterCountFormatter.format(count)} ${unit}`; +} + +export function maxCharacterLimitLabel(maxLength: number) { + return `Max ${characterCountLabel(maxLength)}`; +} + +export function characterRangeLabel(minLength: number, maxLength: number) { + return `${characterCountFormatter.format(minLength)} to ${characterCountLabel(maxLength)}`; +} + +export function minimumCharacterLabel(minLength: number) { + return `at least ${characterCountLabel(minLength)}`; +} + +const reportSubjectTypeNames = [ + "user", + "blog", + "group", + "skin", + "message", + "post", + "post_comment", + "blog_comment", + "skin_comment" +] as const; + +export type ReportSubjectType = (typeof reportSubjectTypeNames)[number]; +export type CommentReportSubjectType = Extract; +export type ContentReportSubjectType = Exclude; +export type DeletableReportSubjectType = Exclude; + +const reportSubjectTypes: ReadonlySet = new Set(reportSubjectTypeNames); + +export function isReportSubjectType(value: string): value is ReportSubjectType { + return reportSubjectTypes.has(value); +} + +export function validUsername(value: string) { + return value.length >= limits.usernameMin && value.length <= limits.usernameMax; +} + +export function validPassword(value: string) { + return value.length >= limits.passwordMin && value.length <= limits.passwordMax; +} + +export function validEmail(value: string) { + return value.length <= limits.emailMax && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +export function canonicalEmail(value: string) { + const email = value.trim().toLowerCase(); + const at = email.indexOf("@"); + if (at <= 0 || at !== email.lastIndexOf("@")) return email; + + let local = email.slice(0, at); + let domain = email.slice(at + 1); + if (domain !== "gmail.com" && domain !== "googlemail.com") return email; + + local = local.split("+", 1)[0].replace(/\./g, ""); + if (!local) return email; + domain = "gmail.com"; + return `${local}@${domain}`; +} + +export function validHandle(value: string) { + return value.length >= limits.handleMin && value.length <= limits.handleMax && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(value); +} + +export function reservedHandle(value: string) { + return reservedHandles.has(value.trim().toLowerCase()); +} diff --git a/src/project.ts b/src/project.ts new file mode 100644 index 0000000..f5da4d9 --- /dev/null +++ b/src/project.ts @@ -0,0 +1,15 @@ +export const sourceProject = { + name: "Bliish.space", + websiteUrl: "https://bliish.space", + repositoryUrl: "https://github.com/bliish-com/bliishspace", + creationYear: 2026, + provider: { + name: "Bliish", + url: "https://bliish.com" + } +}; + +export const sourceProjectDescription = "an open-source project for private social spaces that are easy and affordable to host"; +export const sourceProjectPoweredByPrefix = "This site is powered by"; +export const sourceProjectPoweredByLinkedText = `${sourceProjectPoweredByPrefix} [url=${sourceProject.websiteUrl}]${sourceProject.name}[/url], ${sourceProjectDescription}.`; +export const sourceProjectDetailsLabel = "[more details]"; diff --git a/src/rateLimitLabels.ts b/src/rateLimitLabels.ts new file mode 100644 index 0000000..c423707 --- /dev/null +++ b/src/rateLimitLabels.ts @@ -0,0 +1,25 @@ +import type { RateLimitAction } from "./policy.js"; + +const rateLimitActionLabels: Record = { + "account.write": "Account settings", + "auth.login": "Log in", + "auth.reset": "Password reset", + "auth.signup": "Sign up", + "blog.create": "Blog posts", + "comment.create": "Comments and replies", + "content.write": "Content edits and deletes", + "engagement.write": "Props and unprops", + "group.create": "Group creation", + "message.send": "Private messages", + "notification.write": "Notifications", + "post.create": "Feed and wall posts", + "profile.write": "Profile edits", + "relationship.write": "Relationships and group membership", + "report.create": "Reports", + "skin.create": "Shared skins", + "staff.write": "Staff moderation actions" +}; + +export function rateLimitActionLabel(action: RateLimitAction) { + return rateLimitActionLabels[action]; +} diff --git a/src/roles.ts b/src/roles.ts new file mode 100644 index 0000000..f89c7c8 --- /dev/null +++ b/src/roles.ts @@ -0,0 +1,66 @@ +export const userRoles = ["user", "moderator", "admin"] as const; + +export type UserRole = (typeof userRoles)[number]; +const userRoleSet = new Set(userRoles); + +type RoleUser = { + id?: number; + role?: string | null; +}; + +export type Permission = "admin" | "moderateReports" | "moderateContent" | "banUsers"; + +const permissionsByRole: Record> = { + user: new Set(), + moderator: new Set(["moderateReports", "moderateContent", "banUsers"]), + admin: new Set(["admin", "moderateReports", "moderateContent", "banUsers"]) +}; + +const roleWeight: Record = { + user: 0, + moderator: 1, + admin: 2 +}; + +export function normalizeRole(value: string | null | undefined): UserRole { + return isUserRole(value) ? value : "user"; +} + +export function isUserRole(value: string | null | undefined): value is UserRole { + return typeof value === "string" && userRoleSet.has(value); +} + +function roleFor(user: RoleUser | null | undefined): UserRole { + return normalizeRole(user?.role); +} + +export function hasPermission(user: RoleUser | null | undefined, permission: Permission) { + return permissionsByRole[roleFor(user)].has(permission); +} + +export function isAdminUser(user: RoleUser | null | undefined) { + return hasPermission(user, "admin"); +} + +export function canModerateContent(user: RoleUser | null | undefined) { + return hasPermission(user, "moderateContent"); +} + +export function canViewReports(user: RoleUser | null | undefined) { + return hasPermission(user, "moderateReports"); +} + +export function canBanUsers(user: RoleUser | null | undefined) { + return hasPermission(user, "banUsers"); +} + +export function canBypassVisibility(user: RoleUser | null | undefined) { + return canViewReports(user); +} + +export function canModerateTarget(actor: RoleUser, target: RoleUser) { + if (!actor.id || !target.id || actor.id === target.id) return false; + if (isAdminUser(actor)) return true; + if (!canModerateContent(actor)) return false; + return roleWeight[roleFor(actor)] > roleWeight[roleFor(target)]; +} diff --git a/src/routes/account/index.tsx b/src/routes/account/index.tsx new file mode 100644 index 0000000..c0e9774 --- /dev/null +++ b/src/routes/account/index.tsx @@ -0,0 +1,169 @@ +import type { Hono } from "hono"; +import { anchors } from "../../anchors.js"; +import { requireAuth, requireProfile, visibleProfile } from "../../server/access.js"; +import { csrfToken, destroySession, revokeOtherSessions } from "../../server/auth/session.js"; +import { hashPassword, verifyPassword } from "../../server/auth/password.js"; +import { exportAccountData } from "../../server/db/account.js"; +import { addFavorite, favoriteUsers, removeFavorite } from "../../server/db/relationships.js"; +import { requestVerification } from "../../server/db/email.js"; +import { + defaultNotificationPreferences, + notificationPreferencesForUser, + notifyFavorite, + updateNotificationPreferences +} from "../../server/db/notifications/index.js"; +import { proppedBlogsForViewer } from "../../server/db/blogs/index.js"; +import { postImageFilenamesForAccount, proppedPostsForViewer } from "../../server/db/posts/index.js"; +import { deleteAccount, getUserByEmail, updateEmail, updatePassword, updateProfile, updateTimeZone } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { formId, localBack, verifiedActionForm } from "../../server/http.js"; +import { deletePostImages, deleteProfileImage, deleteProfileThemeSong } from "../../server/media/upload.js"; +import { characterRangeLabel, limits, validEmail, validPassword } from "../../policy.js"; +import { isSupportedTimeZone } from "../../timeZones.js"; +import { notificationPreferenceTypeNames, type NotificationPreferences } from "../../notifications.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { profilePath } from "../../paths.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { DeleteAccountPage, FavoritesPage, PropsPage, SettingsPage } from "../../views/account/index.js"; + +export function registerAccountRoutes(app: Hono) { + app.get("/account", (c) => c.redirect("/settings", 302)); + + app.get("/settings", (c) => { + const user = requireAuth(c); + return settingsPage(c, user); + }); + + app.post("/settings", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "account.write"); + const update = await settingsUpdateFromForm(user, form); + if (!update.ok) return settingsPage(c, user, update.message, 400, update.notificationPreferences); + + if (update.passwordHash) { + updatePassword(user.id, update.passwordHash); + revokeOtherSessions(c, user.id); + } + if (update.email !== user.email) { + updateEmail(user.id, update.email); + await requestVerification(user.id); + } + if (update.timeZone !== user.timeZone) updateTimeZone(user.id, update.timeZone); + updateProfile(user.id, { private: update.private }); + updateNotificationPreferences(user.id, update.notificationPreferences); + return c.redirect("/settings"); + }); + + app.get("/account/export.json", (c) => { + const user = requireAuth(c); + return c.json(exportAccountData(user.id)); + }); + + app.get("/favorites", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.post("/favorites/add", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "relationship.write"); + const id = formId(form); + const { profile } = visibleProfile(c, id); + if (addFavorite(user.id, id)) notifyFavorite(user.id, id); + return c.redirect(localBack(c, profilePath(profile), { fragment: anchors.profileActions })); + }); + + app.post("/favorites", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "relationship.write"); + removeFavorite(user.id, formId(form)); + return c.redirect(localBack(c, "/favorites")); + }); + + app.get("/props", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.get("/account/delete", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.post("/account/delete", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "account.write"); + const current = getUserByEmail(user.email); + const password = field(form, "password"); + if (!current || password.length > limits.passwordMax || !(await verifyPassword(current.passwordHash, password))) { + return c.html(, 400); + } + if (field(form, "confirm") !== "DELETE") { + return c.html(, 400); + } + const profile = requireProfile(user.id); + const postImages = postImageFilenamesForAccount(user.id); + deleteAccount(user.id); + destroySession(c); + await deleteProfileImage(profile.pfp); + await deletePostImages(postImages); + await deleteProfileThemeSong(profile.themeSong); + return c.redirect("/"); + }); +} + +function settingsPage(c: AppContext, user: CurrentUser, message?: string, status: 200 | 400 = 200, notificationPreferences = notificationPreferencesForUser(user.id)) { + return c.html( + , + status + ); +} + +type SettingsUpdate = + | { ok: true; email: string; notificationPreferences: NotificationPreferences; passwordHash?: string; private: boolean; timeZone: string } + | { ok: false; message: string; notificationPreferences: NotificationPreferences }; + +async function settingsUpdateFromForm(user: CurrentUser, form: Record): Promise { + const password = field(form, "password-new"); + const email = field(form, "email").toLowerCase(); + const notificationPreferences = notificationPreferencesFromForm(form); + const visibility = field(form, "profile_visibility"); + const timeZone = field(form, "time_zone"); + + if (password) { + if (!validPassword(password) || password !== field(form, "password-confirm")) { + return settingsError(`New passwords must match and be ${characterRangeLabel(limits.passwordMin, limits.passwordMax)}.`, notificationPreferences); + } + const current = getUserByEmail(user.email); + const oldPassword = field(form, "password-old"); + if (!current || oldPassword.length > limits.passwordMax || !(await verifyPassword(current.passwordHash, oldPassword))) return settingsError("Old password is incorrect.", notificationPreferences); + } + if (!email) return settingsError("Email is required.", notificationPreferences); + if (email !== user.email) { + if (!validEmail(email)) return settingsError("Use a valid email address.", notificationPreferences); + const existing = getUserByEmail(email); + if (existing && existing.id !== user.id) return settingsError("That email is already in use.", notificationPreferences); + } + if (visibility !== "public" && visibility !== "private") return settingsError("Choose a profile visibility setting.", notificationPreferences); + if (!isSupportedTimeZone(timeZone)) return settingsError("Choose a valid time zone.", notificationPreferences); + + return { ok: true, email, notificationPreferences, private: visibility === "private", timeZone, passwordHash: password ? await hashPassword(password) : undefined }; +} + +function notificationPreferencesFromForm(form: Record) { + const preferences = defaultNotificationPreferences(); + for (const type of notificationPreferenceTypeNames) { + preferences[type] = field(form, `notification_${type}`) === "on"; + } + return preferences; +} + +function settingsError(message: string, notificationPreferences: NotificationPreferences): SettingsUpdate { + return { ok: false, message, notificationPreferences }; +} diff --git a/src/routes/admin/automodRules.ts b/src/routes/admin/automodRules.ts new file mode 100644 index 0000000..474dc94 --- /dev/null +++ b/src/routes/admin/automodRules.ts @@ -0,0 +1,57 @@ +import { HTTPException } from "hono/http-exception"; +import { + deleteAutomodRule, + getAutomodRule, + requiredAutomodAction, + requiredAutomodPatternType, + requiredAutomodScope, + saveAutomodRule, + setAutomodRuleEnabled +} from "../../server/db/automod.js"; +import { field } from "../../server/forms.js"; +import { formAction, formId, optionalId, requiredField } from "../../server/http.js"; +import { automodPatternMax } from "../../automodPolicy.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; + +type AutomodActionInput = { actor: CurrentUser; form: Record }; +type AutomodActionResult = { action: string; ruleId: number; metadata?: Record }; +type AutomodRouteAction = (input: AutomodActionInput) => AutomodActionResult; +type AutomodRuleActionName = "save" | "enable" | "disable" | "delete"; + +const adminAutomodHandlers = { + save: ({ actor, form }: AutomodActionInput) => { + const id = optionalId(field(form, "id")) || undefined; + const ruleId = saveAutomodRule({ + id, + name: requiredField(form, "name", limits.shortText, "Rule name is required."), + pattern: requiredField(form, "pattern", automodPatternMax, "Rule pattern is required."), + patternType: requiredAutomodPatternType(field(form, "patternType")), + scope: requiredAutomodScope(field(form, "scope")), + action: requiredAutomodAction(field(form, "automodAction")), + enabled: field(form, "enabled") === "1", + actorId: actor.id + }); + return { action: id ? "update" : "create", ruleId }; + }, + enable: ({ form }: AutomodActionInput) => { + const ruleId = formId(form); + if (!setAutomodRuleEnabled(ruleId, true)) throw new HTTPException(404, { message: "Automod rule not found." }); + return { action: "enable", ruleId }; + }, + disable: ({ form }: AutomodActionInput) => { + const ruleId = formId(form); + if (!setAutomodRuleEnabled(ruleId, false)) throw new HTTPException(404, { message: "Automod rule not found." }); + return { action: "disable", ruleId }; + }, + delete: ({ form }: AutomodActionInput) => { + const ruleId = formId(form); + const rule = getAutomodRule(ruleId); + if (!deleteAutomodRule(ruleId)) throw new HTTPException(404, { message: "Automod rule not found." }); + return { action: "delete", ruleId, metadata: rule ? { subjectLabel: `Automod rule: ${rule.name}` } : undefined }; + } +} satisfies Record; + +export function runAutomodAction(actor: CurrentUser, form: Record): AutomodActionResult { + return formAction(adminAutomodHandlers, field(form, "action"), "Unknown automod action.")({ actor, form }); +} diff --git a/src/routes/admin/index.tsx b/src/routes/admin/index.tsx new file mode 100644 index 0000000..15afc9d --- /dev/null +++ b/src/routes/admin/index.tsx @@ -0,0 +1,155 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireAdmin } from "../../server/access.js"; +import { isProtectedAdminAccount } from "../../server/adminProtection.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { deleteBlog } from "../../server/db/blogs/index.js"; +import { queueEmail } from "../../server/db/email.js"; +import { audit, moderationSubjectAuditMetadata } from "../../server/db/moderation/index.js"; +import { removeFavoriteEdge, searchStaffUserRows } from "../../server/db/staffDashboard.js"; +import { getProfile } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { formId, optionalId, queryText, requiredField, routeId, verifiedActionForm, verifiedForm } from "../../server/http.js"; +import { moderateReport } from "../../server/moderation/actions.js"; +import { limits, validEmail } from "../../policy.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { StaffPage, StaffUserDetailPage, type StaffSection } from "../../views/staff/index.js"; +import { siteSettingsTargetId } from "../../views/staff/siteSettingsTargets.js"; +import { staffDashboardData } from "../staff/dashboardData.js"; +import { optionalReportNoteFromForm, reportActionFromValue } from "../staff/reportForm.js"; +import { runSiteSettingsAction } from "./siteSettings.js"; +import { runAutomodAction } from "./automodRules.js"; +import { runManagedUserAction } from "./managedUsers.js"; +import { runRateLimitAction } from "./rateLimits.js"; + +export function registerAdminRoutes(app: Hono) { + app.get("/admin", (c) => adminPage(c, "overview")); + app.get("/admin/", (c) => adminPage(c, "overview")); + app.get("/admin/users", (c) => adminPage(c, "users")); + app.get("/admin/reports", (c) => adminPage(c, "reports")); + app.get("/admin/automod", (c) => adminPage(c, "automod")); + app.post("/admin/automod", adminAutomodAction); + app.get("/admin/rate-limits", (c) => adminPage(c, "rateLimits")); + app.post("/admin/rate-limits", adminRateLimitsAction); + app.get("/admin/branding", (c) => adminPage(c, "siteSettings")); + app.post("/admin/branding", adminSiteSettingsAction); + app.get("/admin/database", (c) => adminPage(c, "database")); + app.get("/admin/blog", (c) => adminPage(c, "blog")); + app.get("/admin/favorites", (c) => adminPage(c, "favorites")); + app.get("/admin/email", (c) => adminPage(c, "email")); + app.get("/admin/audit", (c) => adminPage(c, "audit")); + app.get("/admin/users/:id", adminUserPage); + app.post("/admin/users/:id", adminUserAction); + app.post("/admin/reports", adminReportsAction); + app.post("/admin/blog/:id/delete", adminBlogDeleteAction); + app.post("/admin/favorites/delete", adminFavoriteDeleteAction); + app.post("/admin/email/send", adminEmailAction); +} + +function adminPage(c: AppContext, section: StaffSection) { + const user = requireAdmin(c); + const openAutomodRuleId = section === "automod" ? optionalId(c.req.query("open")) || undefined : undefined; + const query = section === "users" ? queryText(c, "q", limits.searchQuery) : ""; + const users = query ? searchStaffUserRows(query) : undefined; + return c.html( + + ); +} + +function adminUserPage(c: AppContext) { + const user = requireAdmin(c); + const target = getProfile(routeId(c)); + if (!target) return c.redirect("/admin/users"); + return c.html(); +} + +async function adminUserAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + const target = getProfile(routeId(c)); + if (!target) return c.redirect("/admin/users"); + const action = await runManagedUserAction(user, form, target); + audit(user.id, action, "user", target.id); + return c.redirect(`/admin/users/${target.id}`); +} + +async function adminAutomodAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + const result = runAutomodAction(user, form); + audit(user.id, result.action, "automod_rule", result.ruleId, "", result.metadata ?? {}); + return c.redirect(automodActionRedirect(result)); +} + +async function adminRateLimitsAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedForm(c); + runRateLimitAction(user, form); + return c.redirect("/admin/rate-limits"); +} + +async function adminSiteSettingsAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + await runSiteSettingsAction(user, form); + return c.redirect(adminSiteSettingsRedirect(form)); +} + +async function adminReportsAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + await moderateReport(user, formId(form), reportActionFromValue(field(form, "action")), optionalReportNoteFromForm(form)); + return c.redirect("/admin/reports"); +} + +async function adminBlogDeleteAction(c: AppContext) { + const user = requireAdmin(c); + await verifiedActionForm(c, "staff.write"); + const blogId = routeId(c); + const auditMetadata = moderationSubjectAuditMetadata("blog", blogId); + if (!deleteBlog(blogId, user.id, true)) throw new HTTPException(404, { message: "Blog entry not found." }); + audit(user.id, "delete", "blog", blogId, "", auditMetadata); + return c.redirect("/admin/blog"); +} + +async function adminFavoriteDeleteAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + const userId = formId(form, "userId"); + const favoriteId = formId(form, "favoriteId"); + if (!removeFavoriteEdge(userId, favoriteId)) throw new HTTPException(404, { message: "Favorite not found." }); + audit(user.id, "delete", "favorite", favoriteId); + return c.redirect("/admin/favorites"); +} + +async function adminEmailAction(c: AppContext) { + const user = requireAdmin(c); + const form = await verifiedActionForm(c, "staff.write"); + const to = field(form, "to").toLowerCase(); + if (!validEmail(to)) throw new HTTPException(400, { message: "Use a valid email address." }); + const email = await queueEmail( + to, + requiredField(form, "subject", limits.shortText, "Subject is required."), + requiredField(form, "body", limits.userText, "Body is required.") + ); + audit(user.id, "queue", "email", email.id); + return c.redirect("/admin/email"); +} + +function automodActionRedirect(result: { action: string; ruleId: number }) { + if (result.action === "delete") return "/admin/automod#automod-rules"; + return `/admin/automod?open=${result.ruleId}#automod-rule-${result.ruleId}`; +} + +function adminSiteSettingsRedirect(form: Record) { + const targetId = siteSettingsTargetId(field(form, "mode")); + return targetId ? `/admin/branding#${targetId}` : "/admin/branding"; +} diff --git a/src/routes/admin/managedUsers.ts b/src/routes/admin/managedUsers.ts new file mode 100644 index 0000000..199ddaa --- /dev/null +++ b/src/routes/admin/managedUsers.ts @@ -0,0 +1,53 @@ +import { HTTPException } from "hono/http-exception"; +import { assertMutableManagedUser } from "../../server/adminProtection.js"; +import { hashPassword } from "../../server/auth/password.js"; +import { revokeUserSessions } from "../../server/auth/session.js"; +import { requestPasswordReset } from "../../server/db/email.js"; +import { markUserVerified, setUserBanned, setUserRole, updatePassword } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { formAction } from "../../server/http.js"; +import { characterRangeLabel, limits, validPassword } from "../../policy.js"; +import { isUserRole, type UserRole } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; + +type ManagedUserTarget = { id: number; email: string; verifiedAt?: string | null }; +type ManagedUserActionInput = { actor: CurrentUser; form: Record; target: ManagedUserTarget }; +type ManagedUserAction = (input: ManagedUserActionInput) => void | Promise; +type ManagedUserActionName = "verify" | "ban" | "unban" | "reset" | "role" | "password"; + +const managedUserActions = { + verify: ({ target }: ManagedUserActionInput) => { + if (target.verifiedAt) throw new HTTPException(400, { message: "User is already verified." }); + markUserVerified(target.id); + }, + ban: ({ actor, target }: ManagedUserActionInput) => { + if (target.id === actor.id) throw new HTTPException(400, { message: "You cannot ban your own account." }); + setUserBanned(target.id, true); + revokeUserSessions(target.id); + }, + unban: ({ target }: ManagedUserActionInput) => setUserBanned(target.id, false), + reset: ({ target }: ManagedUserActionInput) => requestPasswordReset(target.email), + role: ({ actor, form, target }: ManagedUserActionInput) => { + const role = requiredUserRole(field(form, "role")); + if (target.id === actor.id && role !== "admin") throw new HTTPException(400, { message: "You cannot remove your own admin role." }); + setUserRole(target.id, role); + }, + password: async ({ form, target }: ManagedUserActionInput) => { + const password = field(form, "password"); + if (!validPassword(password)) throw new HTTPException(400, { message: `Password must be ${characterRangeLabel(limits.passwordMin, limits.passwordMax)}.` }); + updatePassword(target.id, await hashPassword(password)); + revokeUserSessions(target.id); + } +} satisfies Record; + +export async function runManagedUserAction(actor: CurrentUser, form: Record, target: ManagedUserTarget) { + assertMutableManagedUser(target); + const action = field(form, "action"); + await formAction(managedUserActions, action, "Unknown admin action.")({ actor, form, target }); + return action; +} + +function requiredUserRole(value: string): UserRole { + if (isUserRole(value)) return value; + throw new HTTPException(400, { message: "Unknown user role." }); +} diff --git a/src/routes/admin/rateLimits.ts b/src/routes/admin/rateLimits.ts new file mode 100644 index 0000000..50e3933 --- /dev/null +++ b/src/routes/admin/rateLimits.ts @@ -0,0 +1,49 @@ +import { HTTPException } from "hono/http-exception"; +import { audit } from "../../server/db/moderation/index.js"; +import { disableRaidMode, enableRaidMode, rateLimitActionNames, resetRateLimitSettings, saveRateLimitSettings } from "../../server/db/rateLimits.js"; +import { field } from "../../server/forms.js"; +import { formAction } from "../../server/http.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; + +type RateLimitActionInput = { actor: CurrentUser; form: Record }; +type RateLimitRouteAction = (input: RateLimitActionInput) => void; +type RateLimitMode = "reset" | "raid" | "raidOff" | "save"; + +const rateLimitActions = { + reset: ({ actor }: RateLimitActionInput) => { + resetRateLimitSettings(); + audit(actor.id, "reset", "rate_limit", 0); + }, + raid: ({ actor }: RateLimitActionInput) => { + enableRaidMode(actor.id); + audit(actor.id, "enable_raid_mode", "rate_limit", 0); + }, + raidOff: ({ actor }: RateLimitActionInput) => { + disableRaidMode(); + audit(actor.id, "disable_raid_mode", "rate_limit", 0); + }, + save: ({ actor, form }: RateLimitActionInput) => { + saveRateLimitSettings( + rateLimitActionNames.map((action) => ({ + action, + limit: rateLimitNumber(form, `limit_${action}`, "Limit", 0, 10000), + windowSeconds: rateLimitNumber(form, `window_${action}`, "Window", 1, 60 * 60 * 24 * 30) + })), + actor.id + ); + audit(actor.id, "update", "rate_limit", 0); + } +} satisfies Record; + +export function runRateLimitAction(actor: CurrentUser, form: Record) { + formAction(rateLimitActions, field(form, "mode"), "Unknown rate limit action.")({ actor, form }); +} + +function rateLimitNumber(form: Record, name: string, label: string, min: number, max: number) { + const value = Number(field(form, name)); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new HTTPException(400, { message: `${label} must be a whole number from ${min} to ${max}.` }); + } + return value; +} diff --git a/src/routes/admin/siteSettings.ts b/src/routes/admin/siteSettings.ts new file mode 100644 index 0000000..5bdb158 --- /dev/null +++ b/src/routes/admin/siteSettings.ts @@ -0,0 +1,67 @@ +import { HTTPException } from "hono/http-exception"; +import { brandingPaletteFromForm, resetBrandingPalette, saveBrandingPalette } from "../../server/db/branding.js"; +import { audit } from "../../server/db/moderation/index.js"; +import { + normalizeCompanyName, + normalizeContactEmail, + normalizeSiteName, + normalizeSiteText, + saveSiteContact, + saveSiteHome, + saveSiteIdentity, + siteIconFromName, + SiteSettingsValidationError +} from "../../server/db/siteSettings.js"; +import { field } from "../../server/forms.js"; +import { formAction } from "../../server/http.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; + +type SiteSettingsActionInput = { actor: CurrentUser; form: Record }; +type SiteSettingsRouteAction = (input: SiteSettingsActionInput) => void | Promise; +type SiteSettingsActionName = "resetColor" | "color" | "identity" | "home" | "contact"; + +const siteSettingsActions = { + resetColor: ({ actor }: SiteSettingsActionInput) => { + resetBrandingPalette(); + audit(actor.id, "reset_color_theme", "app_setting", 0); + }, + color: ({ actor, form }: SiteSettingsActionInput) => { + saveBrandingPalette(brandingPaletteFromForm(form)); + audit(actor.id, "update_color_theme", "app_setting", 0); + }, + identity: async ({ actor, form }: SiteSettingsActionInput) => { + const icon = await siteIconFromName(field(form, "headerIcon")); + saveSiteIdentity({ + name: normalizeSiteName(field(form, "siteName")), + tagline: normalizeSiteText(field(form, "siteTagline"), limits.siteTagline), + headerIconName: icon.name, + headerIconSvg: icon.svg + }); + audit(actor.id, "update_site_identity", "app_setting", 0); + }, + home: ({ actor, form }: SiteSettingsActionInput) => { + saveSiteHome({ + announcement: normalizeSiteText(field(form, "announcement"), limits.siteAnnouncement), + welcomeText: normalizeSiteText(field(form, "welcomeText"), limits.siteWelcomeText) + }); + audit(actor.id, "update_site_home", "app_setting", 0); + }, + contact: ({ actor, form }: SiteSettingsActionInput) => { + saveSiteContact({ + email: normalizeContactEmail(field(form, "contactEmail")), + companyName: normalizeCompanyName(field(form, "companyName")), + mailingAddress: normalizeSiteText(field(form, "mailingAddress"), limits.contactAddress) + }); + audit(actor.id, "update_site_contact", "app_setting", 0); + } +} satisfies Record; + +export async function runSiteSettingsAction(actor: CurrentUser, form: Record) { + try { + await formAction(siteSettingsActions, field(form, "mode"), "Unknown site settings action.")({ actor, form }); + } catch (error) { + if (error instanceof SiteSettingsValidationError) throw new HTTPException(400, { message: error.message }); + throw error; + } +} diff --git a/src/routes/auth/index.tsx b/src/routes/auth/index.tsx new file mode 100644 index 0000000..177609b --- /dev/null +++ b/src/routes/auth/index.tsx @@ -0,0 +1,206 @@ +import type { Hono } from "hono"; +import { featuredCommunityGroups, listGroups } from "../../server/db/groups.js"; +import { + createUser, + getProfile, + getUserByEmail, + HandleReservedError, + markUserVerified, + newestUsers, + setUserRole, + updatePassword +} from "../../server/db/users.js"; +import { installBuiltinSkins } from "../../server/db/skins.js"; +import { consumeResetToken, consumeVerificationToken, requestPasswordReset, requestVerification } from "../../server/db/email.js"; +import { siteSettings } from "../../server/db/siteSettings.js"; +import { createSession, csrfToken, currentUser, destroySession, revokeUserSessions } from "../../server/auth/session.js"; +import { hashPassword, verifyPassword } from "../../server/auth/password.js"; +import { scanAutomodSubmission, type AutomodSubmissionScan } from "../../server/db/automod.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, verifiedActionForm, verifiedForm } from "../../server/http.js"; +import { env } from "../../server/env.js"; +import { smtpConfigured } from "../../server/email/smtp.js"; +import { canonicalEmail, characterRangeLabel, limits, validEmail, validHandle, validPassword, validUsername } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { LogoutPage, ResetApplyPage, ResetRequestPage, ResetUnavailablePage, SignUpPage, VerifyPage } from "../../views/auth/index.js"; +import { LandingPage } from "../../views/home/index.js"; + +export function registerAuthRoutes(app: Hono) { + app.get("/", (c) => (currentUser(c) ? c.redirect("/home") : landing(c))); + app.get("/login", landing); + + app.post("/login", async (c) => { + const form = await verifiedActionForm(c, "auth.login", emailSubject); + const viewer = currentUser(c); + const email = field(form, "email").toLowerCase(); + const password = field(form, "password"); + const user = getUserByEmail(email); + if (!user || user.bannedAt || password.length > limits.passwordMax || !(await verifyPassword(user.passwordHash, password))) { + return landingPage(c, viewer, "Email or password is incorrect.", 400); + } + createSession(c, user.id); + return c.redirect("/home"); + }); + + app.get("/logout", (c) => { + const user = currentUser(c); + if (!user) return c.redirect("/"); + return c.html(); + }); + app.post("/logout", async (c) => { + await verifiedForm(c); + destroySession(c); + return c.redirect("/"); + }); + + app.get("/signup", (c) => c.html( + + )); + app.post("/signup", async (c) => { + const form = await verifiedActionForm(c, "auth.signup", emailSubject); + const viewer = currentUser(c); + const signup = await signupFromForm(form); + if (!signup.ok) return signupPage(c, viewer, signup.email, signup.message, 400, signup.handle); + + let userId: number; + try { + userId = createUser(signup.user); + } catch (error) { + if (error instanceof HandleReservedError) { + const message = error.reason === "reserved" ? "That handle is reserved." : "That handle is already in use."; + return signupPage(c, viewer, signup.user.email, message, 400, signup.user.handle); + } + throw error; + } + signup.automod.createReports({ subjectType: "user", subjectId: userId, authorId: userId }); + if (userId === env.adminUserId) setUserRole(userId, "admin"); + installBuiltinSkins(); + await requestVerification(userId); + createSession(c, userId); + return c.redirect("/account/profile"); + }); + + app.get("/reset", (c) => { + const user = currentUser(c); + if (!smtpConfigured()) return c.html(); + return c.html(); + }); + app.post("/reset", async (c) => { + if (!smtpConfigured()) return c.html(); + const form = await verifiedActionForm(c, "auth.reset", emailSubject); + const user = currentUser(c); + await requestPasswordReset(field(form, "email").toLowerCase()); + return c.html(); + }); + app.get("/reset/:token", (c) => c.html()); + app.post("/reset/:token", async (c) => { + const form = await verifiedActionForm(c, "auth.reset", `reset-token:${c.req.param("token")}`); + const user = currentUser(c); + const token = c.req.param("token"); + const password = field(form, "password"); + if (!validPassword(password) || password !== field(form, "confirm")) { + return c.html( + , + 400 + ); + } + const userId = consumeResetToken(token); + if (!userId) return c.html(, 400); + updatePassword(userId, await hashPassword(password)); + revokeUserSessions(userId); + if (user?.id === userId) destroySession(c); + return c.redirect("/login"); + }); + app.get("/verify/:token", (c) => { + const userId = consumeVerificationToken(c.req.param("token")); + if (userId) markUserVerified(userId); + return c.html(); + }); +} + +function landing(c: AppContext) { + const user = currentUser(c); + return landingPage(c, user); +} + +function prefillEmail(value: string | undefined) { + return value?.trim().slice(0, limits.emailMax); +} + +function landingPage(c: AppContext, user: CurrentUser | null, message?: string, status: 200 | 400 = 200) { + return c.html( + , + status + ); +} + +function signupPage(c: AppContext, user: CurrentUser | null, initialEmail?: string, message?: string, status: 200 | 400 = 200, initialHandle?: string) { + return c.html(, status); +} + +type SignupResult = + | { ok: true; automod: AutomodSubmissionScan; user: { username: string; email: string; handle: string; passwordHash: string } } + | { ok: false; email: string; handle: string; message: string }; +type SignupAutomodResult = + | { ok: true; submission: AutomodSubmissionScan } + | { ok: false; message: string }; + +async function signupFromForm(form: Record): Promise { + const username = field(form, "username"); + const handle = field(form, "handle").toLowerCase(); + const email = field(form, "email").toLowerCase(); + const password = field(form, "password"); + + if (field(form, "terms") !== "accepted") { + return signupError(email, handle, "You must be 13+ and agree to the terms, privacy, and rules to sign up."); + } + if (!validUsername(username) || !validHandle(handle) || !validEmail(email) || !validPassword(password) || password !== field(form, "confirm")) { + return signupError( + email, + handle, + `Use a display name with ${characterRangeLabel(limits.usernameMin, limits.usernameMax)}, ` + + `a handle with ${characterRangeLabel(limits.handleMin, limits.handleMax)}, ` + + `a valid email, and matching passwords of ${characterRangeLabel(limits.passwordMin, limits.passwordMax)}.` + ); + } + if (getUserByEmail(email)) return signupError(email, handle, "That email is already in use."); + + const automod = scanSignupProfile(username, handle); + if (!automod.ok) return signupError(email, handle, automod.message); + + return { ok: true, automod: automod.submission, user: { username, email, handle, passwordHash: await hashPassword(password) } }; +} + +function signupError(email: string, handle: string, message: string): SignupResult { + return { ok: false, email, handle, message }; +} + +function scanSignupProfile(username: string, handle: string): SignupAutomodResult { + try { + return { ok: true, submission: scanAutomodSubmission("profile", username, handle) }; + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return { ok: false, message }; + throw error; + } +} + +function emailSubject(form: Record) { + const email = canonicalEmail(field(form, "email")); + return `email:${email || "missing"}`; +} diff --git a/src/routes/blogs/index.tsx b/src/routes/blogs/index.tsx new file mode 100644 index 0000000..5e11b59 --- /dev/null +++ b/src/routes/blogs/index.tsx @@ -0,0 +1,205 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { anchors } from "../../anchors.js"; +import { csrfToken, currentUser } from "../../server/auth/session.js"; +import { requireAuth, requireBlog, requireOwnerOrAdmin, requireProfile, visibleProfile } from "../../server/access.js"; +import { scanAutomodSubmission } from "../../server/db/automod.js"; +import { + addBlogComment, + addBlogProp, + allBlogs, + blogsByCategory, + blogCommentsFor, + canViewBlog, + createBlog, + deleteBlog, + deleteBlogComment, + removeBlogProp, + updateBlog +} from "../../server/db/blogs/index.js"; +import { audit, moderationSubjectAuditMetadata } from "../../server/db/moderation/index.js"; +import { notifyBlogComment, notifyBlogProp } from "../../server/db/notifications/index.js"; +import { addCommentFromForm, deleteCommentFromRoute } from "../../server/comments/actions.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, localBack, requiredBlogBody, requiredField, routeId, verifiedActionForm } from "../../server/http.js"; +import { canDeleteAsOwnerOrModerator, canModerateAuthor } from "../../server/moderation/guards.js"; +import { previewFromRows } from "../../server/pagination.js"; +import { defaultBlogCategory, isBlogCategory, limits } from "../../policy.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { BlogEntryPage, BlogListPage, EditBlogPage, NewBlogPage } from "../../views/blogs/index.js"; +import { blogCommentsPath, blogPath, profileBlogPath } from "../../paths.js"; + +export function registerBlogRoutes(app: Hono) { + app.get("/blog", (c) => { + const user = currentUser(c); + return c.html( + + ); + }); + app.get("/blog/new", (c) => c.html()); + app.post("/blog/new", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "blog.create"); + try { + const fields = blogFields(form); + const blogId = createBlog(user.id, fields.title, fields.bodyHtml, fields.category, fields.privacyLevel, fields.commentsEnabled); + fields.automod.createReports({ subjectType: "blog", subjectId: blogId, authorId: user.id }); + return c.redirect(profileBlogPath(requireProfile(user.id))); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return c.html(, 400); + throw error; + } + }); + app.get("/blog/category/:category", (c) => { + const user = currentUser(c); + const category = decodeCategory(c.req.param("category")); + return c.html( + + ); + }); + app.get("/b/:id/edit", (c) => { + const user = requireAuth(c); + const blog = requireBlog(routeId(c)); + requireOwnerOrAdmin(user, blog.authorId, "You cannot edit this blog entry."); + return c.html(); + }); + app.post("/b/:id/edit", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "content.write"); + const blog = requireBlog(routeId(c)); + requireOwnerOrAdmin(user, blog.authorId, "You cannot edit this blog entry."); + try { + const fields = blogFields(form); + updateBlog(blog.authorId, blog.id, fields.title, fields.bodyHtml, fields.category, fields.privacyLevel, fields.commentsEnabled, field(form, "pinned") === "1"); + fields.automod.createReports({ subjectType: "blog", subjectId: blog.id, authorId: blog.authorId }); + return c.redirect(blogPath(blog)); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return c.html(, 400); + throw error; + } + }); + app.post("/b/:id/prop", async (c) => blogPropAction(c, addBlogProp, notifyBlogProp)); + app.post("/b/:id/unprop", async (c) => blogPropAction(c, removeBlogProp)); + app.post("/b/:id/delete", async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "content.write"); + const blog = requireBlog(routeId(c)); + if (!canDeleteAsOwnerOrModerator(user, blog.authorId, [blog.authorId])) throw new HTTPException(403, { message: "You cannot delete this blog entry." }); + const elevated = canModerateAuthor(user, blog.authorId) && user.id !== blog.authorId; + const auditMetadata = elevated ? moderationSubjectAuditMetadata("blog", blog.id) : {}; + deleteBlog(blog.id, user.id, elevated); + if (elevated) audit(user.id, "delete", "blog", blog.id, "", auditMetadata); + return c.redirect(profileBlogPath(requireProfile(blog.authorId))); + }); + app.post("/b/:id/comments", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "comment.create"); + const { blog } = viewableBlog(c); + if (!blog.commentsEnabled) throw new HTTPException(403, { message: "Comments are disabled for this entry." }); + const reply = Boolean(field(form, "parentId")); + return addCommentFromForm(c, user, { + form, + subjectType: "blog_comment", + redirect: (commentId) => + localBack(c, reply ? blogCommentsPath(blog) : blogPath(blog), { + avoid: reply ? [blogPath(blog)] : undefined, + fragment: anchors.comment(commentId) + }), + add: (textHtml, parentId) => addBlogComment(blog.id, user.id, textHtml, parentId, user), + afterAdd: notifyBlogComment + }); + }); + app.post("/b/comments/:id/delete", (c) => + deleteCommentFromRoute(c, { subjectType: "blog_comment", delete: deleteBlogComment, fallback: "/blog", redirectFragment: anchors.comments }) + ); + app.get("/b/:id/comments", (c) => blogPage(c, true)); + app.get("/b/:id", (c) => blogPage(c)); +} + +function blogPage(c: AppContext, fullComments = false) { + const { user, blog } = viewableBlog(c); + const commentsHref = blogCommentsPath(blog); + const commentRows = blogCommentsFor(blog.id, user, fullComments ? undefined : limits.commentsPage + 1); + const comments = fullComments ? { items: commentRows, hasMore: false } : previewFromRows(commentRows, limits.commentsPage); + return c.html( + + ); +} + +function viewableBlog(c: AppContext) { + const viewer = currentUser(c); + const blog = requireBlog(routeId(c), viewer); + const { user } = visibleProfile(c, blog.authorId); + if (!canViewBlog(user, blog)) throw new HTTPException(403, { message: "You cannot view this blog entry." }); + return { user, blog }; +} + +async function blogPropAction( + c: AppContext, + action: (blogId: number, userId: number) => boolean | void, + afterChange?: (blogId: number, userId: number) => void +) { + const user = requireAuth(c); + await verifiedActionForm(c, "engagement.write"); + const blog = requireBlog(routeId(c), user); + visibleProfile(c, blog.authorId); + if (!canViewBlog(user, blog)) throw new HTTPException(403, { message: "You cannot prop this blog entry." }); + if (action(blog.id, user.id)) afterChange?.(blog.id, user.id); + return c.redirect(localBack(c, blogPath(blog), { fragment: anchors.blog(blog) })); +} + +function blogFields(form: Record) { + const fields = { + title: requiredField(form, "title", limits.shortText, "Title is required."), + bodyHtml: requiredBlogBody(form, "body", limits.contentBody, "Body is required."), + category: blogCategory(form), + privacyLevel: privacyLevel(form), + commentsEnabled: field(form, "commentsEnabled") === "1" + }; + return { ...fields, automod: scanAutomodSubmission("blog", fields.title, fields.bodyHtml) }; +} + +function blogCategory(form: Record) { + const category = field(form, "category") || defaultBlogCategory; + if (isBlogCategory(category)) return category; + throw new HTTPException(400, { message: "Unknown blog category." }); +} + +function decodeCategory(value: string) { + let category: string; + try { + category = decodeURIComponent(value); + } catch { + throw new HTTPException(400, { message: "Invalid blog category." }); + } + if (isBlogCategory(category)) return category; + throw new HTTPException(404, { message: "Blog category not found." }); +} + +function privacyLevel(form: Record) { + const value = Number(field(form, "privacy") || "0"); + if (Number.isInteger(value) && value >= 0 && value <= 2) return value; + throw new HTTPException(400, { message: "Unknown blog privacy setting." }); +} diff --git a/src/routes/groups/index.tsx b/src/routes/groups/index.tsx new file mode 100644 index 0000000..3264568 --- /dev/null +++ b/src/routes/groups/index.tsx @@ -0,0 +1,129 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { csrfToken } from "../../server/auth/session.js"; +import { scanAutomodSubmission } from "../../server/db/automod.js"; +import { requireAuth, requireGroup, requireOwnerOrAdmin, visibleGroup } from "../../server/access.js"; +import { + createGroup, + deleteGroup, + groupMembers, + isGroupMember, + joinGroup, + leaveGroup, + listGroups, + updateGroup +} from "../../server/db/groups.js"; +import { audit, moderationSubjectAuditMetadata } from "../../server/db/moderation/index.js"; +import { postImageFilenamesForGroup, postsForGroupPage } from "../../server/db/posts/index.js"; +import { badFormRequestMessage, localBack, requiredField, requiredUserText, routeId, verifiedActionForm } from "../../server/http.js"; +import { deletePostImages } from "../../server/media/upload.js"; +import { canDeleteAsOwnerOrModerator, canModerateAuthor } from "../../server/moderation/guards.js"; +import { beforeParam, paginationHref } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import { groupPath } from "../../paths.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { GroupFormPage, GroupListPage, GroupPage } from "../../views/groups/index.js"; + +export function registerGroupRoutes(app: Hono) { + app.get("/groups", (c) => { + const user = requireAuth(c); + return c.html(); + }); + app.get("/groups/new", (c) => c.html()); + app.post("/groups/new", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "group.create"); + try { + const fields = groupFields(form); + const groupId = createGroup(user.id, fields.name, fields.descriptionHtml); + fields.automod.createReports({ subjectType: "group", subjectId: groupId, authorId: user.id }); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return c.html(, 400); + throw error; + } + return c.redirect("/groups"); + }); + app.post("/g/:id/join", joinGroupAction); + app.post("/g/:id/leave", leaveGroupAction); + app.get("/g/:id/edit", (c) => { + const user = requireAuth(c); + const group = requireGroup(routeId(c)); + requireOwnerOrAdmin(user, group.ownerId, "You cannot edit this group."); + return c.html(); + }); + app.post("/g/:id/edit", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "content.write"); + const group = requireGroup(routeId(c)); + requireOwnerOrAdmin(user, group.ownerId, "You cannot edit this group."); + try { + const fields = groupFields(form); + updateGroup(group.ownerId, group.id, fields.name, fields.descriptionHtml); + fields.automod.createReports({ subjectType: "group", subjectId: group.id, authorId: group.ownerId }); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return c.html(, 400); + throw error; + } + return c.redirect(groupPath(group)); + }); + app.post("/g/:id/delete", async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "content.write"); + const group = requireGroup(routeId(c)); + if (!canDeleteAsOwnerOrModerator(user, group.ownerId, [group.ownerId])) throw new HTTPException(403, { message: "You cannot delete this group." }); + const elevated = canModerateAuthor(user, group.ownerId) && user.id !== group.ownerId; + const auditMetadata = elevated ? moderationSubjectAuditMetadata("group", group.id) : {}; + const postImages = postImageFilenamesForGroup(group.id); + if (deleteGroup(group.id, user.id, elevated)) await deletePostImages(postImages); + if (elevated) audit(user.id, "delete", "group", group.id, "", auditMetadata); + return c.redirect("/groups"); + }); + app.get("/g/:id/posts", (c) => groupPage(c, true)); + app.get("/g/:id", (c) => groupPage(c)); +} + +function groupPage(c: AppContext, fullPosts = false) { + const { user, group } = visibleGroup(c, routeId(c)); + const member = isGroupMember(group.id, user.id); + const before = c.req.query(beforeParam); + const postsBaseHref = `${groupPath(group)}/posts`; + const postsPage = fullPosts ? postsForGroupPage(group.id, user, { before, limit: limits.listPage }) : null; + const postsPreviewPage = fullPosts ? null : postsForGroupPage(group.id, user, { limit: limits.groupPostPreview }); + return c.html( + + ); +} + +async function joinGroupAction(c: AppContext) { + requireAuth(c); + await verifiedActionForm(c, "relationship.write"); + const { user, group } = visibleGroup(c, routeId(c)); + joinGroup(group.id, user.id); + return c.redirect(localBack(c, groupPath(group))); +} + +async function leaveGroupAction(c: AppContext) { + const { user, group } = visibleGroup(c, routeId(c)); + await verifiedActionForm(c, "relationship.write"); + leaveGroup(group.id, user.id); + return c.redirect(localBack(c, groupPath(group))); +} + +function groupFields(form: Record) { + const name = requiredField(form, "groupname", limits.shortText, "Name is required."); + const descriptionHtml = requiredUserText(form, "desc", limits.groupText, "Description is required."); + return { name, descriptionHtml, automod: scanAutomodSubmission("group", name, descriptionHtml) }; +} diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..f0069d7 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,37 @@ +import type { Hono } from "hono"; +import { registerAccountRoutes } from "./account/index.js"; +import { registerAdminRoutes } from "./admin/index.js"; +import { registerAuthRoutes } from "./auth/index.js"; +import { registerBlogRoutes } from "./blogs/index.js"; +import { registerGroupRoutes } from "./groups/index.js"; +import { registerMessageRoutes } from "./messages/index.js"; +import { registerModerationRoutes } from "./moderation/index.js"; +import { registerNotificationRoutes } from "./notifications/index.js"; +import { registerPeopleRoutes } from "./people/index.js"; +import { registerPostRoutes } from "./posts/index.js"; +import { registerProfileRoutes } from "./profile/index.js"; +import { registerReportRoutes } from "./reports/index.js"; +import { registerSearchRoutes } from "./search/index.js"; +import { registerSkinRoutes } from "./skins/index.js"; +import { registerSiteRoutes } from "./site/index.js"; +import type { AppBindings } from "../server/context.js"; + +type RouteRegistrar = (app: Hono) => void; + +export const routeRegistrars: readonly RouteRegistrar[] = [ + registerAuthRoutes, + registerProfileRoutes, + registerSearchRoutes, + registerPeopleRoutes, + registerPostRoutes, + registerReportRoutes, + registerNotificationRoutes, + registerBlogRoutes, + registerMessageRoutes, + registerGroupRoutes, + registerSkinRoutes, + registerAccountRoutes, + registerAdminRoutes, + registerModerationRoutes, + registerSiteRoutes +]; diff --git a/src/routes/messages/index.tsx b/src/routes/messages/index.tsx new file mode 100644 index 0000000..2813a2e --- /dev/null +++ b/src/routes/messages/index.tsx @@ -0,0 +1,182 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireAuth } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { scanAutomodSubmission } from "../../server/db/automod.js"; +import { + conversationParticipantForHandle, + conversationsForUser, + deleteMessageFor, + markConversationRead, + messagesForConversation, + sendMessage +} from "../../server/db/messages/index.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, localBack, requiredField, requiredUserText, routeId, verifiedActionForm } from "../../server/http.js"; +import { beforeParam, paginationHref } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import { messageFormContexts } from "../../messages.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { MessageConversation, UserProfile } from "../../models.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { MessagesPage, NewMessagePage } from "../../views/messages/index.js"; +import { conversationsBeforeParam, messageConversationPath, messagesPath, newMessagePathBase } from "../../paths.js"; +import { assertMessageRecipient, forwardedProfileMessage, queryRecipient, recipientHandle, visibleMessageRecipient } from "./recipients.js"; + +export function registerMessageRoutes(app: Hono) { + app.get(messagesPath, (c) => { + const user = requireAuth(c); + return messagesPage(c, user); + }); + + app.get(newMessagePathBase, (c) => { + const user = requireAuth(c); + return composeMessagePage(c, user); + }); + + app.post(newMessagePathBase, async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "message.send"); + let receiver: UserProfile | undefined; + try { + receiver = visibleMessageRecipient(c, field(form, "to")); + assertMessageRecipient(user, receiver); + const subject = requiredField(form, "subject", limits.shortText, "Subject is required."); + const bodyHtml = requiredUserText(form, "body", limits.userText, "Message body is required."); + const automod = scanAutomodSubmission("message", subject, bodyHtml); + const messageId = sendMessage(user.id, receiver.id, subject, bodyHtml); + if (!messageId) { + const message = "Message could not be sent."; + if (isConversationReply(form)) return messagesPage(c, user, { selectedHandle: receiver.handle, form, message }, 400); + return composeMessageError(c, user, form, receiver, message); + } + automod.createReports({ subjectType: "message", subjectId: messageId, authorId: user.id }); + return c.redirect(`${messageConversationPath(receiver.handle)}#message-${messageId}`); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) { + if (isConversationReply(form) && receiver) { + return messagesPage(c, user, { selectedHandle: receiver.handle, form, message }, 400); + } + return composeMessageError(c, user, form, receiver, message); + } + throw error; + } + }); + + app.post(`${messagesPath}/:id/delete`, async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "content.write"); + if (!deleteMessageFor(user.id, routeId(c))) throw new HTTPException(404, { message: "Message not found." }); + return c.redirect(localBack(c, messagesPath)); + }); +} + +type MessagesPageOptions = { + form?: Record; + message?: string; + selectedHandle?: string; +}; + +function messagesPage(c: AppContext, user: CurrentUser, options: MessagesPageOptions = {}, status: 200 | 400 = 200) { + const conversationsBefore = c.req.query(conversationsBeforeParam); + let conversationPage = conversationsForUser(user.id, { before: conversationsBefore, limit: limits.listPage }); + const selected = selectedParticipant(c, user, options.selectedHandle ?? c.req.query("with"), conversationPage.items[0]); + const threadBefore = c.req.query(beforeParam); + + if (selected) { + markConversationRead(selected.id, user.id); + conversationPage = conversationsForUser(user.id, { before: conversationsBefore, limit: limits.listPage }); + } + + const threadPage = selected ? messagesForConversation(user.id, selected.id, { before: threadBefore, limit: limits.listPage }) : null; + const selectedConversation = selected ? conversationPage.items.find((conversation) => conversation.otherUserId === selected.id) : undefined; + + const conversationListHref = selected ? messageConversationPath(selected.handle) : messagesPath; + const threadHref = selected ? messageConversationPath(selected.handle, conversationsBefore) : null; + return c.html( + , + status + ); +} + +function composeMessagePage(c: AppContext, user: CurrentUser) { + const recipient = queryRecipient(c); + const forwarded = forwardedProfileMessage(c); + const instant = c.req.query("mode") === "instant"; + const subject = (c.req.query("subject") ?? forwarded?.subject ?? (instant ? "Instant message" : "")).slice(0, limits.shortText); + return c.html( + + ); +} + +function composeMessageError( + c: AppContext, + user: CurrentUser, + form: Record, + recipient: UserProfile | undefined, + message: string +) { + return c.html( + , + 400 + ); +} + +function isConversationReply(form: Record) { + return field(form, "context") === messageFormContexts.conversation; +} + +function selectedParticipant(c: AppContext, user: CurrentUser, handle: string | undefined, fallback?: MessageConversation) { + if (!handle && fallback) { + return { + id: fallback.otherUserId, + username: fallback.otherName, + handle: fallback.otherHandle, + pfp: fallback.otherPfp + }; + } + if (!handle) return undefined; + const conversationParticipant = conversationParticipantForHandle(user.id, recipientHandle(handle)); + if (conversationParticipant) return conversationParticipant; + return visibleMessageRecipient(c, handle); +} + +function replySubject(draft: string, latestSubject: string | undefined, username: string | undefined) { + if (draft) return draft.slice(0, limits.shortText); + if (latestSubject) return latestSubject.match(/^re:/i) ? latestSubject : `Re: ${latestSubject}`.slice(0, limits.shortText); + return username ? `Conversation with ${username}`.slice(0, limits.shortText) : ""; +} diff --git a/src/routes/messages/recipients.ts b/src/routes/messages/recipients.ts new file mode 100644 index 0000000..9e541d0 --- /dev/null +++ b/src/routes/messages/recipients.ts @@ -0,0 +1,42 @@ +import { HTTPException } from "hono/http-exception"; +import type { CurrentUser } from "../../currentUser.js"; +import { profilePath } from "../../paths.js"; +import type { UserProfile } from "../../models.js"; +import { visibleProfile } from "../../server/access.js"; +import type { AppContext } from "../../server/context.js"; +import { env } from "../../server/env.js"; +import { optionalId } from "../../server/http.js"; +import { profileByHandle } from "../../server/db/users.js"; + +export function queryRecipient(c: AppContext) { + const to = c.req.query("to"); + return to ? visibleMessageRecipient(c, to) : undefined; +} + +export function visibleMessageRecipient(c: AppContext, value: string) { + const handle = recipientHandle(value); + const profile = profileByHandle(handle); + if (!profile) throw new HTTPException(400, { message: "Recipient not found." }); + return visibleProfile(c, profile.id).profile; +} + +export function assertMessageRecipient(sender: CurrentUser, receiver: UserProfile) { + if (sender.id === receiver.id) throw new HTTPException(400, { message: "You cannot send messages to yourself." }); +} + +export function recipientHandle(value: string) { + const handle = value.trim().replace(/^@/, "").toLowerCase(); + if (!handle) throw new HTTPException(400, { message: "Recipient handle is required." }); + return handle; +} + +export function forwardedProfileMessage(c: AppContext) { + if (c.req.query("forward") !== "profile") return undefined; + const id = optionalId(c.req.query("id")); + if (!id) throw new HTTPException(400, { message: "Invalid profile to forward." }); + const profile = visibleProfile(c, id).profile; + return { + subject: `Profile: ${profile.username}`, + body: `Check out ${profile.username}'s profile:\n${env.baseUrl}${profilePath(profile)}` + }; +} diff --git a/src/routes/moderation/index.tsx b/src/routes/moderation/index.tsx new file mode 100644 index 0000000..efa074e --- /dev/null +++ b/src/routes/moderation/index.tsx @@ -0,0 +1,44 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireModerator, requireProfile } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { field } from "../../server/forms.js"; +import { formId, routeId, verifiedActionForm } from "../../server/http.js"; +import { moderateReport, moderateUserBan } from "../../server/moderation/actions.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { StaffPage } from "../../views/staff/index.js"; +import { profilePath } from "../../paths.js"; +import { staffDashboardData } from "../staff/dashboardData.js"; +import { optionalReportNoteFromForm, reportActionFromValue } from "../staff/reportForm.js"; + +export function registerModerationRoutes(app: Hono) { + app.get("/moderation", moderationReportsPage); + app.get("/moderation/reports", moderationReportsPage); + app.post("/moderation/reports", moderationReportsAction); + app.post("/moderation/users/:id/ban", moderationBanAction); +} + +function moderationReportsPage(c: AppContext) { + const user = requireModerator(c); + return c.html(); +} + +async function moderationReportsAction(c: AppContext) { + const user = requireModerator(c); + const form = await verifiedActionForm(c, "staff.write"); + await moderateReport(user, formId(form), reportActionFromValue(field(form, "action")), optionalReportNoteFromForm(form)); + return c.redirect("/moderation/reports"); +} + +async function moderationBanAction(c: AppContext) { + const user = requireModerator(c); + const form = await verifiedActionForm(c, "staff.write"); + const target = moderateUserBan(user, routeId(c), banStateFromAction(field(form, "action"))); + return c.redirect(profilePath(requireProfile(target.id))); +} + +function banStateFromAction(action: string) { + if (action === "ban") return true; + if (action === "unban") return false; + throw new HTTPException(400, { message: "Unknown moderation action." }); +} diff --git a/src/routes/notifications/index.tsx b/src/routes/notifications/index.tsx new file mode 100644 index 0000000..ba0a40f --- /dev/null +++ b/src/routes/notifications/index.tsx @@ -0,0 +1,35 @@ +import type { Hono } from "hono"; +import { requireAuth } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { markVisibleNotificationsRead, notificationsForUser, unreadNotificationCount } from "../../server/db/notifications/index.js"; +import { localBack, verifiedActionForm } from "../../server/http.js"; +import { beforeParam, paginationHref } from "../../server/pagination.js"; +import { notificationsPath } from "../../paths.js"; +import type { AppBindings } from "../../server/context.js"; +import { NotificationsPage } from "../../views/notifications/index.js"; + +export function registerNotificationRoutes(app: Hono) { + app.get(notificationsPath, (c) => { + const user = requireAuth(c); + const before = c.req.query(beforeParam); + const unreadCount = unreadNotificationCount(user.id); + const page = notificationsForUser(user, { before }); + return c.html( + + ); + }); + + app.post(notificationsPath, async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "notification.write"); + markVisibleNotificationsRead(user); + return c.redirect(localBack(c, notificationsPath)); + }); +} diff --git a/src/routes/people/index.tsx b/src/routes/people/index.tsx new file mode 100644 index 0000000..8066459 --- /dev/null +++ b/src/routes/people/index.tsx @@ -0,0 +1,99 @@ +import type { Hono } from "hono"; +import { requireAuth, requireProfile, visibleProfile } from "../../server/access.js"; +import { csrfToken, currentUser } from "../../server/auth/session.js"; +import { + acceptFriend, + blockedUsers, + blockUser, + friendsFor, + pendingRequestsFor, + removeFriend, + requestFriend, + sentRequestsFor, + unblockUser +} from "../../server/db/relationships.js"; +import { notifyFriendAccepted } from "../../server/db/notifications/index.js"; +import { listUsersPage } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { formAction, formId, localBack, verifiedActionForm } from "../../server/http.js"; +import { beforeParam, paginationHref } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { BlocksPage, PeoplePage, RequestsPage } from "../../views/people/index.js"; +import { profilePath } from "../../paths.js"; + +type RelationshipActionInput = { c: AppContext; user: CurrentUser; id: number }; +type RelationshipAction = (input: RelationshipActionInput) => void; +type FriendActionName = "add" | "accept" | "remove"; +type BlockActionName = "block" | "unblock"; + +const friendActions = { + add: ({ c, user, id }: RelationshipActionInput) => { + visibleProfile(c, id); + if (requestFriend(user.id, id) === "accepted") notifyFriendAccepted(user.id, id); + }, + accept: ({ user, id }: RelationshipActionInput) => { + if (acceptFriend(id, user.id)) notifyFriendAccepted(user.id, id); + }, + remove: ({ user, id }: RelationshipActionInput) => removeFriend(user.id, id) +} satisfies Record; + +const blockActions = { + block: ({ c, user, id }: RelationshipActionInput) => { + visibleProfile(c, id); + blockUser(user.id, id); + }, + unblock: ({ user, id }: RelationshipActionInput) => unblockUser(user.id, id) +} satisfies Record; + +export function registerPeopleRoutes(app: Hono) { + app.get("/browse", (c) => { + const user = currentUser(c); + const before = c.req.query(beforeParam); + const page = listUsersPage(user, { before }); + return c.html( + + ); + }); + + app.get("/friends", (c) => { + const user = requireAuth(c); + const profile = requireProfile(user.id); + return c.html(); + }); + + app.post("/friends", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "relationship.write"); + const id = formId(form); + const action = field(form, "action"); + formAction(friendActions, action, "Unknown friend action.")({ c, user, id }); + return c.redirect(localBack(c, profilePath(requireProfile(id)))); + }); + + app.get("/requests", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.get("/blocks", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.post("/blocks", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "relationship.write"); + const id = formId(form); + formAction(blockActions, field(form, "action"), "Unknown block action.")({ c, user, id }); + return c.redirect(localBack(c, "/blocks")); + }); +} diff --git a/src/routes/posts/index.tsx b/src/routes/posts/index.tsx new file mode 100644 index 0000000..169850d --- /dev/null +++ b/src/routes/posts/index.tsx @@ -0,0 +1,176 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { anchors } from "../../anchors.js"; +import { requireAuth, visibleGroup, visibleProfile } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { scanAutomodSubmission } from "../../server/db/automod.js"; +import { profileByHandle } from "../../server/db/users.js"; +import { + addPostComment, + addPostProp, + canInteractWithPost, + canPostToGroup, + canPostToWall, + createGroupPost, + createWallPost, + deletePost, + deletePostComment, + feedPageForUser, + getPost, + getVisiblePost, + postCommentsFor, + removePostProp +} from "../../server/db/posts/index.js"; +import { audit, moderationSubjectAuditMetadata } from "../../server/db/moderation/index.js"; +import { notifyPostComment, notifyPostProp } from "../../server/db/notifications/index.js"; +import { addCommentFromForm, deleteCommentFromRoute } from "../../server/comments/actions.js"; +import { fileField } from "../../server/forms.js"; +import { localBack, requiredUserText, routeId, verifiedActionForm, withFragment } from "../../server/http.js"; +import { deletePostImage, savePostImage } from "../../server/media/upload.js"; +import { canDeleteAsOwnerOrModerator, canModerateAuthor } from "../../server/moderation/guards.js"; +import { beforeParam, paginationHref } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import type { UserProfile } from "../../models.js"; +import { FeedPage, PostPage } from "../../views/posts/index.js"; +import { groupPath, postPath, profilePath } from "../../paths.js"; + +export function registerPostRoutes(app: Hono) { + app.get("/feed", (c) => { + const user = requireAuth(c); + const before = c.req.query(beforeParam); + const page = feedPageForUser(user, { before, limit: limits.feedPosts }); + return c.html( + + ); + }); + + app.post("/feed", (c) => createWallPostAction(c, (user) => user.id, (_profile, postId) => withFragment("/feed", anchors.post(postId)))); + app.post("/u/:handle/wall", (c) => + createWallPostAction(c, () => routeProfileHandle(c), (profile, postId) => withFragment(profilePath(profile), anchors.post(postId))) + ); + + app.post("/g/:id/posts", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "post.create"); + const { group } = visibleGroup(c, routeId(c)); + if (!canPostToGroup(user.id, group.id)) throw new HTTPException(403, { message: "Only group members can post here." }); + const post = await createPostFromForm(form, (bodyHtml, media) => createGroupPost(user.id, group.id, bodyHtml, media)); + post.automod.createReports({ subjectType: "post", subjectId: post.id, authorId: user.id }); + return c.redirect(withFragment(groupPath(group), anchors.post(post.id))); + }); + + app.post("/p/comments/:id/delete", (c) => + deleteCommentFromRoute(c, { subjectType: "post_comment", delete: deletePostComment, fallback: "/feed", redirectFragment: anchors.comments }) + ); + + app.post("/p/:id/prop", async (c) => propAction(c, addPostProp, notifyPostProp)); + app.post("/p/:id/unprop", async (c) => propAction(c, removePostProp)); + + app.post("/p/:id/comments", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "comment.create"); + const post = visiblePost(c, user); + if (!canInteractWithPost(post, user.id)) throw new HTTPException(403, { message: "You cannot comment on this post." }); + return addCommentFromForm(c, user, { + form, + subjectType: "post_comment", + redirect: (commentId) => localBack(c, postPath(post), { fragment: anchors.comment(commentId) }), + add: (textHtml, parentId) => addPostComment(post.id, user.id, textHtml, parentId, user), + afterAdd: notifyPostComment + }); + }); + + app.post("/p/:id/delete", async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "content.write"); + const post = getPost(routeId(c), user); + if (!post) throw new HTTPException(404, { message: "Post not found." }); + const ownerIds = [post.authorId, post.wallUserId, post.groupOwnerId]; + if (!canDeleteAsOwnerOrModerator(user, post.authorId, ownerIds)) throw new HTTPException(403, { message: "You cannot delete this post." }); + const elevated = canModerateAuthor(user, post.authorId) && !ownerIds.includes(user.id); + const auditMetadata = elevated ? moderationSubjectAuditMetadata("post", post.id) : {}; + const deletedMedia = deletePost(post.id, user.id, elevated); + if (deletedMedia === false) throw new HTTPException(403, { message: "You cannot delete this post." }); + await deletePostImage(deletedMedia); + if (elevated) audit(user.id, "delete", "post", post.id, "", auditMetadata); + return c.redirect(localBack(c, "/feed", { avoid: [postPath(post)] })); + }); + + app.get("/p/:id", (c) => { + const user = requireAuth(c); + const post = visiblePost(c, user); + return c.html( + + ); + }); +} + +async function createWallPostAction( + c: AppContext, + profileIdFor: (user: CurrentUser) => number, + redirectFor: (profile: UserProfile, postId: number) => string +) { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "post.create"); + const { profile } = visibleProfile(c, profileIdFor(user)); + if (!canPostToWall(user.id, profile.id)) throw new HTTPException(403, { message: "You cannot post on this wall." }); + const post = await createPostFromForm(form, (bodyHtml, media) => createWallPost(user.id, profile.id, bodyHtml, media)); + post.automod.createReports({ subjectType: "post", subjectId: post.id, authorId: user.id }); + return c.redirect(redirectFor(profile, post.id)); +} + +async function propAction( + c: AppContext, + action: (postId: number, userId: number) => boolean | void, + afterChange?: (postId: number, userId: number) => void +) { + const user = requireAuth(c); + await verifiedActionForm(c, "engagement.write"); + const post = visiblePost(c, user); + if (!canInteractWithPost(post, user.id)) throw new HTTPException(403, { message: "You cannot prop this post." }); + if (action(post.id, user.id)) afterChange?.(post.id, user.id); + return c.redirect(localBack(c, postPath(post), { fragment: anchors.post(post) })); +} + +function visiblePost(c: AppContext, user: CurrentUser) { + const post = getVisiblePost(routeId(c), user); + if (!post) throw new HTTPException(404, { message: "Post not found." }); + return post; +} + +function routeProfileHandle(c: AppContext) { + const profile = profileByHandle(c.req.param("handle") ?? ""); + if (!profile) throw new HTTPException(404, { message: "User not found." }); + return profile.id; +} + +async function saveOptionalPostImage(form: Record) { + const file = fileField(form, "media"); + return file ? await savePostImage(file) : undefined; +} + +async function createPostFromForm(form: Record, create: (bodyHtml: string, media?: string) => number) { + const media = await saveOptionalPostImage(form); + try { + const bodyHtml = requiredUserText(form, "text", limits.postText, "Post text is required."); + const automod = scanAutomodSubmission("post", bodyHtml); + return { id: create(bodyHtml, media), automod }; + } catch (error) { + await deletePostImage(media); + throw error; + } +} diff --git a/src/routes/profile/edit/actions.ts b/src/routes/profile/edit/actions.ts new file mode 100644 index 0000000..dca4dff --- /dev/null +++ b/src/routes/profile/edit/actions.ts @@ -0,0 +1,161 @@ +import { HTTPException } from "hono/http-exception"; +import { requireProfile } from "../../../server/access.js"; +import { scanAutomodSubmission } from "../../../server/db/automod.js"; +import { defaultInterestNames, defaultInterests, type UserProfile } from "../../../models.js"; +import { updateProfile, type ProfileUpdate } from "../../../server/db/users.js"; +import { field, fileField } from "../../../server/forms.js"; +import { deleteProfileImage, deleteProfileThemeSong, saveProfileImage, saveProfileThemeSong } from "../../../server/media/upload.js"; +import { characterRangeLabel, limits, validUsername } from "../../../policy.js"; +import { sanitizeSkinHtml, sanitizeUserText } from "../../../server/security/html.js"; +import { normalizeSocialLinks, socialLinkPlatforms, type SocialLinks } from "../../../socialLinks.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { isProfileEditSectionName, type ProfileEditSectionName } from "../../../views/profile/edit/index.js"; + +type ProfileEditActionInput = { user: CurrentUser; form: Record }; +type ProfileEditAction = (input: ProfileEditActionInput) => void | Promise; + +const profileEditActions = { + name: updateProfileName, + profilePhoto: updateProfilePhoto, + themeSong: updateThemeSong, + bio: updateProfileBio, + interests: updateProfileInterests, + socialLinks: updateProfileSocialLinks, + skin: updateProfileSkin +} satisfies Record; + +export async function applyProfileEditSection(user: CurrentUser, form: Record, section: string) { + const editSection = profileEditSection(section); + if (!editSection) throw new HTTPException(400, { message: "Unknown profile edit section." }); + await profileEditActions[editSection]({ user, form }); +} + +export function updateProfileStatus(userId: number, form: Record) { + const status = { + status: field(form, "status").slice(0, limits.shortText), + currentVibe: field(form, "currentVibe").slice(0, limits.shortText) + }; + updateScannedProfile(userId, { status }, status.status, status.currentVibe); +} + +export function profileEditSection(section: string) { + return isProfileEditSectionName(section) ? section : undefined; +} + +export function profileWithSubmittedFields(profile: UserProfile, section: string, form: Record) { + switch (section) { + case "name": + return { ...profile, username: field(form, "username").slice(0, limits.usernameMax) }; + case "bio": + return { ...profile, bioHtml: field(form, "bio").slice(0, limits.userText) }; + case "skin": + return { ...profile, skinHtml: field(form, "skin").slice(0, limits.skinHtml) }; + case "interests": + return { ...profile, interests: interestsFromForm(form) }; + case "socialLinks": + return { + ...profile, + socialLinks: { + ...profile.socialLinks, + ...socialLinkFields(form) + } + }; + default: + return profile; + } +} + +function updateProfileName({ user, form }: ProfileEditActionInput) { + const username = field(form, "username").slice(0, limits.usernameMax); + if (!validUsername(username)) throw new HTTPException(400, { message: `Display name must be ${characterRangeLabel(limits.usernameMin, limits.usernameMax)}.` }); + updateScannedProfile(user.id, { username }, username); +} + +function updateProfileBio({ user, form }: ProfileEditActionInput) { + const bioHtml = sanitizeUserText(field(form, "bio").slice(0, limits.userText)); + updateScannedProfile(user.id, { bioHtml }, bioHtml); +} + +function updateProfileSkin({ user, form }: ProfileEditActionInput) { + const skinHtml = sanitizeSkinHtml(field(form, "skin").slice(0, limits.skinHtml)); + updateScannedProfile(user.id, { skinHtml }, skinHtml); +} + +function updateProfileInterests({ user, form }: ProfileEditActionInput) { + const interests = interestsFromForm(form); + updateScannedProfile(user.id, { interests }, ...Object.values(interests)); +} + +function updateProfileSocialLinks({ user, form }: ProfileEditActionInput) { + const socialLinks = socialLinksFromForm(form); + updateScannedProfile(user.id, { socialLinks }, ...Object.values(socialLinks)); +} + +async function updateProfilePhoto({ user, form }: ProfileEditActionInput) { + await replaceProfileMedia({ + form, + current: requireProfile(user.id).pfp, + missingMessage: "Choose an image to upload.", + remove: deleteProfileImage, + save: saveProfileImage, + update: (filename) => updateProfile(user.id, { pfp: filename }) + }); +} + +async function updateThemeSong({ user, form }: ProfileEditActionInput) { + await replaceProfileMedia({ + form, + current: requireProfile(user.id).themeSong, + missingMessage: "Choose an audio file to upload.", + remove: deleteProfileThemeSong, + save: saveProfileThemeSong, + update: (filename) => updateProfile(user.id, { themeSong: filename }) + }); +} + +function updateScannedProfile(userId: number, update: ProfileUpdate, ...parts: string[]) { + const automod = scanAutomodSubmission("profile", ...parts); + updateProfile(userId, update); + automod.createReports({ subjectType: "user", subjectId: userId, authorId: userId }); +} + +async function replaceProfileMedia(input: { + current: string; + form: Record; + missingMessage: string; + remove: (filename: string) => Promise; + save: (file: File) => Promise; + update: (filename: string) => void; +}) { + const file = fileField(input.form, "file"); + if (!file || file.size === 0) throw new HTTPException(400, { message: input.missingMessage }); + const saved = await input.save(file); + if (!saved) throw new HTTPException(400, { message: input.missingMessage }); + try { + input.update(saved); + } catch (error) { + await input.remove(saved); + throw error; + } + await input.remove(input.current); +} + +function interestsFromForm(form: Record) { + const interests = { ...defaultInterests }; + for (const name of defaultInterestNames) { + interests[name] = field(form, `interests_${name}`).slice(0, limits.interest); + } + return interests; +} + +function socialLinksFromForm(form: Record) { + return normalizeSocialLinks(socialLinkFields(form)); +} + +function socialLinkFields(form: Record): Partial { + const links: Partial = {}; + for (const platform of socialLinkPlatforms) { + links[platform.id] = field(form, `social_${platform.id}`).slice(0, limits.socialLinkUrl); + } + return links; +} diff --git a/src/routes/profile/index.tsx b/src/routes/profile/index.tsx new file mode 100644 index 0000000..d981e02 --- /dev/null +++ b/src/routes/profile/index.tsx @@ -0,0 +1,184 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireAuth, requireProfile, visibleProfile } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { blogsForUser } from "../../server/db/blogs/index.js"; +import { brandingSettings } from "../../server/db/branding.js"; +import { listGroups } from "../../server/db/groups.js"; +import { feedPageForUser } from "../../server/db/posts/index.js"; +import { friendCountFor, hasBlocked, pendingRequestsFor, visibleFriendsFor } from "../../server/db/relationships.js"; +import { siteSettings } from "../../server/db/siteSettings.js"; +import { incrementViews, newestUsers, profileByHandle } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, verifiedActionForm } from "../../server/http.js"; +import { beforeParam, previewFromRows } from "../../server/pagination.js"; +import { limits, validHandle } from "../../policy.js"; +import { isSkinColorGenerateIntent, skinColorPaletteFromHtml, skinStyleCodeFromColorForm } from "../../skins/colorPalette.js"; +import { SocialLinkValidationError } from "../../socialLinks.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { profilePath } from "../../paths.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { BlogListPage } from "../../views/blogs/index.js"; +import { HomePage } from "../../views/home/index.js"; +import { PeoplePage } from "../../views/people/index.js"; +import { ProfilePage } from "../../views/profile/index.js"; +import { ProfileEditPage, ProfileStatusPage } from "../../views/profile/edit/index.js"; +import { applyProfileEditSection, profileEditSection, profileWithSubmittedFields, updateProfileStatus } from "./edit/actions.js"; +import { profilePageData } from "./profilePageData.js"; + +export function registerProfileRoutes(app: Hono) { + app.get("/home", (c) => { + const user = requireAuth(c); + const profile = requireProfile(user.id); + const feedPage = feedPageForUser(user, { limit: limits.feedPosts }); + const newestPreview = previewFromRows(newestUsers(user, limits.newestPeople + 1), limits.newestPeople); + const newestGroupsPreview = previewFromRows(listGroups(user, limits.newestCommunities + 1), limits.newestCommunities); + return c.html( + + ); + }); + + app.get("/account/profile", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.post("/account/profile", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "profile.write"); + const section = field(form, "section"); + if (section === "skin" && isSkinColorGenerateIntent(form)) return profileSkinGenerate(c, user, form); + + try { + await applyProfileEditSection(user, form, section); + } catch (error) { + if (error instanceof SocialLinkValidationError) return profileEditError(c, user, section, error.message, form); + const message = badFormRequestMessage(error); + if (message) return profileEditError(c, user, section, message, form); + throw error; + } + return c.redirect(profileEditRedirect(section)); + }); + + app.get("/account/status", (c) => { + const user = requireAuth(c); + return c.html(); + }); + + app.post("/account/status", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "profile.write"); + updateProfileStatus(user.id, form); + return c.redirect(profilePath(requireProfile(user.id))); + }); + + app.get("/u/:handle/friends", (c) => { + const profile = profileForHandle(c); + const { user } = visibleProfile(c, profile.id); + return c.html( + + ); + }); + + app.get("/u/:handle/blog", (c) => { + const profile = profileForHandle(c); + const { user } = visibleProfile(c, profile.id); + return c.html( + + ); + }); + + app.get("/u/:handle/wall", (c) => profilePage(c, profileForHandle(c).id, true)); + + app.get("/u/:handle", (c) => profilePage(c, profileForHandle(c).id)); +} + +function profilePage(c: AppContext, id: number, fullWall = false) { + const { profile, user, friendship } = visibleProfile(c, id); + const before = c.req.query(beforeParam); + const profileProps = profilePageData({ + user, + csrf: csrfToken(c), + profile, + fullWall, + before, + friendship, + blockedByMe: user ? hasBlocked(user.id, profile.id) : false + }); + incrementViews(profile.id, user?.id); + return c.html(); +} + +function profileEditError(c: AppContext, user: CurrentUser, section: string, message: string, form: Record) { + const profile = profileWithSubmittedFields(requireProfile(user.id), section, form); + const palette = brandingSettings().palette; + return c.html( + , + 400 + ); +} + +function profileSkinGenerate(c: AppContext, user: CurrentUser, form: Record) { + const profile = requireProfile(user.id); + const palette = brandingSettings().palette; + const fallback = skinColorPaletteFromHtml(field(form, "skin") || profile.skinHtml, palette); + return c.html( + + ); +} + +function profileEditRedirect(section: string) { + const targetId = profileEditSection(section); + return targetId ? `/account/profile#${targetId}` : "/account/profile"; +} + +function profileForHandle(c: AppContext) { + const handle = (c.req.param("handle") ?? "").toLowerCase(); + if (!validHandle(handle)) throw new HTTPException(404, { message: "User not found." }); + const profile = profileByHandle(handle); + if (!profile) throw new HTTPException(404, { message: "User not found." }); + return profile; +} diff --git a/src/routes/profile/profilePageData.ts b/src/routes/profile/profilePageData.ts new file mode 100644 index 0000000..6d3a010 --- /dev/null +++ b/src/routes/profile/profilePageData.ts @@ -0,0 +1,51 @@ +import { blogsForUser } from "../../server/db/blogs/index.js"; +import { canPostToWall, postsForProfilePage } from "../../server/db/posts/index.js"; +import { friendCountFor, visibleFriendsFor } from "../../server/db/relationships.js"; +import type { Friendship, UserProfile } from "../../models.js"; +import { env } from "../../server/env.js"; +import { paginationHref, previewFromRows } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { profileBlogPath, profileFriendsPath, profilePath, profileWallPath } from "../../paths.js"; +import type { ProfilePageProps } from "../../views/profile/index.js"; + +export function profilePageData(props: { + user: CurrentUser | null; + csrf: string; + profile: UserProfile; + fullWall?: boolean; + before?: string; + friendship?: Friendship; + blockedByMe?: boolean; +}): ProfilePageProps { + const wallBaseHref = profileWallPath(props.profile); + const wallPage = props.fullWall ? postsForProfilePage(props.profile.id, props.user, { before: props.before, limit: limits.listPage }) : null; + const wallPreviewPage = props.fullWall ? null : postsForProfilePage(props.profile.id, props.user, { limit: limits.profileWallPreview }); + const frontRow = previewFromRows(visibleFriendsFor(props.profile.id, props.user, limits.profileFrontRow + 1), limits.profileFrontRow); + const blogPreview = previewFromRows(blogsForUser(props.profile.id, props.user, limits.profileBlogPreview + 1), limits.profileBlogPreview); + + return { + user: props.user, + csrf: props.csrf, + profile: props.profile, + profileUrlLabel: profileUrlDisplay(profilePath(props.profile)), + friendCount: friendCountFor(props.profile.id, props.user), + frontRow: frontRow.items, + friendsHref: frontRow.hasMore ? profileFriendsPath(props.profile) : null, + blogs: blogPreview.items, + blogHref: blogPreview.hasMore ? profileBlogPath(props.profile) : null, + wallPosts: wallPage ? wallPage.items : wallPreviewPage?.items ?? [], + fullWall: props.fullWall, + wallNextHref: wallPage?.nextCursor ? paginationHref(wallBaseHref, wallPage.nextCursor) : null, + wallResetHref: wallPage && props.before ? wallBaseHref : null, + wallViewAllHref: wallPreviewPage?.nextCursor ? wallBaseHref : null, + canPost: props.user ? canPostToWall(props.user.id, props.profile.id) : false, + friendship: props.friendship, + blockedByMe: props.blockedByMe, + protectedAdminProfile: props.profile.id === env.adminUserId + }; +} + +function profileUrlDisplay(path: string) { + return new URL(path, `${env.baseUrl}/`).href.replace(/^https?:\/\//, ""); +} diff --git a/src/routes/reports/index.tsx b/src/routes/reports/index.tsx new file mode 100644 index 0000000..83ffe11 --- /dev/null +++ b/src/routes/reports/index.tsx @@ -0,0 +1,99 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireAuth, requireBlog, requireGroup, requireSkin, visibleProfile } from "../../server/access.js"; +import { csrfToken } from "../../server/auth/session.js"; +import { canViewBlog } from "../../server/db/blogs/index.js"; +import { commentParentId } from "../../server/db/comments.js"; +import { canViewGroup } from "../../server/db/groups.js"; +import { canViewMessage } from "../../server/db/messages/index.js"; +import { createReport } from "../../server/db/moderation/index.js"; +import { commentTargetForSubject, getModerationSubject } from "../../server/db/moderation/subjects.js"; +import { getVisiblePost } from "../../server/db/posts/index.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, optionalId, optionalReportSubjectType, requiredReportSubjectType, requiredUserText, verifiedActionForm } from "../../server/http.js"; +import { limits, type ReportSubjectType } from "../../policy.js"; +import { reportPathBase } from "../../paths.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { ReportPage } from "../../views/reports/index.js"; + +export function registerReportRoutes(app: Hono) { + app.get(reportPathBase, (c) => { + const user = requireAuth(c); + const subjectType = optionalReportSubjectType(c.req.query("type")); + const subjectId = optionalId(c.req.query("id")); + if (!subjectType || !subjectId) return reportPage(c, user, undefined, undefined, "Open a report link from the page you want to report.", 400); + assertReportSubject(c, user, subjectType, subjectId); + return reportPage(c, user, subjectType, subjectId); + }); + + app.post(reportPathBase, async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "report.create"); + try { + const subjectType = requiredReportSubjectType(field(form, "type")); + const subjectId = requiredReportSubjectId(form); + assertReportSubject(c, user, subjectType, subjectId); + const subject = getModerationSubject(subjectType, subjectId); + createReport(user.id, subjectType, subjectId, requiredUserText(form, "reason", limits.userText, "Report reason is required."), subject?.authorId); + return reportPage(c, user, subjectType, subjectId, undefined, 200, "Report sent."); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) { + return reportPage(c, user, optionalReportSubjectType(field(form, "type")), optionalId(field(form, "id")), message, 400); + } + throw error; + } + }); +} + +function reportPage(c: AppContext, user: CurrentUser, subjectType?: ReportSubjectType, subjectId?: number, error?: string, status: 200 | 400 = 200, message?: string) { + return c.html(, status); +} + +function requiredReportSubjectId(form: Record) { + const subjectId = optionalId(field(form, "id")); + if (!subjectId) throw new HTTPException(400, { message: "Report subject is required." }); + return subjectId; +} + +function assertReportSubject(c: AppContext, user: CurrentUser, subjectType: ReportSubjectType, subjectId: number) { + const commentTarget = commentTargetForSubject(subjectType); + if (commentTarget) { + const parentId = commentParentId(commentTarget, subjectId, user); + if (!parentId) throw new HTTPException(404, { message: "Report subject not found." }); + assertReportSubject(c, user, commentTarget, parentId); + return; + } + + switch (subjectType) { + case "post": + if (!getVisiblePost(subjectId, user)) throw new HTTPException(404, { message: "Report subject not found." }); + return; + case "user": + visibleProfile(c, subjectId); + return; + case "blog": { + const blog = requireBlog(subjectId); + visibleProfile(c, blog.authorId); + if (!canViewBlog(user, blog)) throw new HTTPException(403, { message: "You cannot report this blog entry." }); + return; + } + case "group": { + const group = requireGroup(subjectId); + visibleProfile(c, group.ownerId); + if (!canViewGroup(user, group.id)) throw new HTTPException(404, { message: "Report subject not found." }); + return; + } + case "skin": { + const skin = requireSkin(subjectId); + if (skin.authorId !== null) visibleProfile(c, skin.authorId); + return; + } + case "message": + if (!canViewMessage(user.id, subjectId)) throw new HTTPException(404, { message: "Report subject not found." }); + return; + default: + throw new HTTPException(400, { message: "Unknown report subject." }); + } +} diff --git a/src/routes/search/index.tsx b/src/routes/search/index.tsx new file mode 100644 index 0000000..e749f40 --- /dev/null +++ b/src/routes/search/index.tsx @@ -0,0 +1,15 @@ +import type { Hono } from "hono"; +import { csrfToken, currentUser } from "../../server/auth/session.js"; +import { searchSite } from "../../server/db/search.js"; +import { queryText } from "../../server/http.js"; +import { limits } from "../../policy.js"; +import type { AppBindings } from "../../server/context.js"; +import { SearchPage } from "../../views/search/index.js"; + +export function registerSearchRoutes(app: Hono) { + app.get("/search", (c) => { + const user = currentUser(c); + const query = queryText(c, ["q", "search"], limits.searchQuery); + return c.html(); + }); +} diff --git a/src/routes/site/index.test.ts b/src/routes/site/index.test.ts new file mode 100644 index 0000000..79080ad --- /dev/null +++ b/src/routes/site/index.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppBindings } from "../../server/context.js"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadSiteApp() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-site-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + process.env.BLIISH_BASE_URL = "https://example.test"; + + const client = await import("../../server/db/client.js"); + db = client.sqlite; + const schema = await import("../../server/db/schema.js"); + schema.initializeDatabase(); + + const { indexingMiddleware } = await import("../../server/indexing/middleware.js"); + const { registerSiteRoutes } = await import("./index.js"); + const app = new Hono(); + app.use(async (c, next) => { + c.set("currentUser", null); + c.set("csrfToken", "token"); + await next(); + }); + app.use(indexingMiddleware()); + registerSiteRoutes(app); + return app; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; + delete process.env.BLIISH_BASE_URL; +}); + +describe("site pages", () => { + it("renders source project structured data on the source page", async () => { + const app = await loadSiteApp(); + + const html = await (await app.request("/source")).text(); + + expect(html).toContain(' PageSeo; + title: (settings: SiteSettings) => string; + body: (settings: SiteSettings) => ViewChild; +}; + +const sourceProjectLicenseUrl = "https://www.gnu.org/licenses/gpl-3.0.html"; +const sourceProjectLanguage = "TypeScript"; +const sourceProjectRuntime = "Node.js"; + +const staticPages: readonly StaticPage[] = [ + { + path: "/about", + title: (settings) => `About ${settings.identity.name}`, + body: (settings) => ( + <> +

{settings.home.welcomeText || siteMarketingDescription(settings)}

+

+

This site is administrated and hosted by . Don't take this site seriously I'm hosting it on an HP Pavilion for fun and to learn more about TypeScript.

+ + ) + }, + { + path: "/source", + seo: sourceProjectSeo, + title: () => `${sourceProject.name} source code`, + body: () => ( + <> +

You can inspect it, change it, and run your own copy.

+

Maintainer: {sourceProject.provider.name}.

+

Primary language: {sourceProjectLanguage}. Runtime: {sourceProjectRuntime}.

+

The code is released under the GPL-3.0-only license.

+

Repository: {sourceProject.repositoryUrl}

+ + ) + }, + { + path: "/license", + title: () => "License", + body: () => ( + <> +

{sourceProject.name} is released under GPL-3.0-only.

+

The license lets people use, study, share, and modify the software, with the conditions described in the project license file.

+

Official license text: gnu.org/licenses/gpl-3.0.html

+

Source code: {sourceProject.repositoryUrl}

+ + ) + }, + { + path: "/credits", + title: () => "Credits", + body: () => ( + <> +

{sourceProject.name} is made by {sourceProject.provider.name} and contributors.

+

The app also depends on open source packages listed in the project source.

+

Bundled third-party material is listed in the project notice file.

+ + ) + }, + { + path: "/contact", + title: () => "Contact", + body: (settings) => ( + <> +

For support, privacy questions, copyright notices, or security concerns, use the contact information below.

+ {contactEmail(settings)} +

+ {settings.contact.companyName} + {mailingAddress(settings)} +

+ + ) + }, + { + path: "/privacy", + title: () => "Privacy", + body: (settings) => ( + <> +

{settings.identity.name} stores the information needed to run a social network: account email, username, password hash, session and CSRF records, profile fields, posts, blogs, comments, groups, messages, shared skins, uploaded file references, reports, moderation records, and timestamps.

+

Uploaded profile pictures, post images, and theme songs are stored on disk.

+

The default app does not include ads, analytics pixels, tracking scripts, or third-party cookies.

+

The app uses cookies for login sessions, form protection, and the optional color theme.

+

You can change profile visibility from account settings. Private profiles are visible to the profile owner, friends, and staff. Blog entries have their own privacy setting.

+

Custom profile skins can include sanitized third-party HTTPS images, fonts, and embeds chosen by the profile author. Visiting those profiles may contact those third-party services.

+

You can export your account data or delete your account from account settings. Server logs, backups, and retained moderation records depend on the site operator.

+ {settings.contact.email ?

Questions: {settings.contact.email}

: null} + + ) + }, + { + path: "/terms", + title: () => "Terms", + body: (settings) => ( + <> +

By using {settings.identity.name}, you agree to these terms, the Privacy page, and the Rules.

+

You must be at least EIGHTEEN years old to create an account. Use your own account and do not try to break, overload, or abuse the site.

+

You are responsible for what you post, upload, message, share, and add to custom profile skins. Do not post content you do not have the right to use.

+

You keep ownership of your content. You allow {settings.identity.name} to store, display, and process it as needed to run the site.

+

Staff may remove content, limit features, or suspend accounts that break these terms, the rules, or the security of the service.

+ {dmcaNotice(settings)} +

{settings.identity.name} is provided as-is, without warranties. To the fullest extent allowed by law, {settings.contact.companyName} is not liable for indirect, incidental, special, consequential, or punitive damages from use of the site.

+ + ) + }, + { + path: "/help", + title: () => "Help", + body: () => ( + <> +

Use edit profile to change your name, picture, theme song, bio, interests, social links, and skin HTML. Your public profile address is set during signup.

+

Wall posts and group posts support one image, props, comments, and comment replies. Blog entries support categories, privacy, pinning, props, comments, and comment replies.

+

Skins are shared from the skins page and can be previewed before applying them to your profile.

+ + ) + }, + { + path: "/rules", + title: () => "Rules", + body: () => ( + <> +

YOU MUST BE EIGHTEEN OR OVER TO USE SILLYSPACE. No exceptions. Most similar sites are populated by children and teenagers; we're glad you have your own spaces, please let us have ours. You'll be more than welcome when the time comes!

+

Be decent to other people and do not use the site to make their lives harder.

+

Do not harass people, threaten people, spam, impersonate others, evade moderation, or post illegal content such as CSAM or piracy links.

+

Do not post malware, phishing links, stolen private information, sexual exploitation, or content that targets minors.

+

Do not use profile skins, embeds, uploads, or links to attack visitors, hide malicious content, or break the site.

+

Staff may remove content, restrict features, or suspend accounts when needed to keep the site usable and safe for all.

+

This may become a fandom-focused space. We do not censor content beyond banning VISUAL sexual depictions (fanart that shows genitalia or explicit sexual acts) of characters under the age of 14 or who appear to be under the age of 14 that are hyperrealistic or could be mistaken for real life. Please email me if you have any questions. + + ) + } +]; + +export function registerSiteRoutes(app: Hono) { + for (const page of staticPages) { + app.get(page.path, (c) => { + const settings = siteSettings(); + return plainPage(c, page.title(settings), page.body(settings), 200, page.seo?.(settings)); + }); + } +} + +function sourceProjectSeo(): PageSeo { + const sourcePageUrl = absoluteUrl("/source"); + return { + description: `${sourceProject.name} source code, license, repository, maintainer, language, and runtime.`, + jsonLd: { + "@context": "https://schema.org", + "@id": `${sourcePageUrl}#source-code`, + "@type": "SoftwareSourceCode", + name: sourceProject.name, + description: sourceProjectDescription, + url: sourceProject.repositoryUrl, + mainEntityOfPage: sourcePageUrl, + codeRepository: sourceProject.repositoryUrl, + codeSampleType: "full application", + programmingLanguage: sourceProjectLanguage, + runtimePlatform: sourceProjectRuntime, + license: sourceProjectLicenseUrl, + isAccessibleForFree: true, + creator: { + "@type": "Organization", + name: sourceProject.provider.name, + url: sourceProject.provider.url + }, + publisher: { + "@type": "Organization", + name: sourceProject.provider.name, + url: sourceProject.provider.url + } + } + }; +} + +function contactEmail(settings: SiteSettings) { + return settings.contact.email + ?

{settings.contact.email}

+ :

No contact email has been configured yet.

; +} + +function mailingAddress(settings: SiteSettings) { + const lines = settings.contact.mailingAddress.split("\n").map((line) => line.trim()).filter(Boolean); + return lines.length ? ( + <> +
+ {lines.map((line) => ( + <> + {line} +
+ + ))} + + ) : null; +} + +function dmcaNotice(settings: SiteSettings) { + return settings.contact.email ? ( +

DMCA notices can be sent to {settings.contact.email}. Include the copyrighted work, the allegedly infringing URL or material, your contact information, a good-faith statement, a statement that the notice is accurate under penalty of perjury, and your physical or electronic signature. Repeat infringers may have content removed or accounts terminated.

+ ) : ( +

Copyright notices can be sent through the contact information on the Contact page. Repeat infringers may have content removed or accounts terminated.

+ ); +} diff --git a/src/routes/skins/index.tsx b/src/routes/skins/index.tsx new file mode 100644 index 0000000..2d23fcc --- /dev/null +++ b/src/routes/skins/index.tsx @@ -0,0 +1,223 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { anchors } from "../../anchors.js"; +import { requireAuth, requireOwnerOrAdmin, requireProfile, requireSkin, visibleProfile } from "../../server/access.js"; +import { csrfToken, currentUser } from "../../server/auth/session.js"; +import { scanAutomodSubmission } from "../../server/db/automod.js"; +import { brandingSettings } from "../../server/db/branding.js"; +import { + addSkinComment, + createSkin, + deleteSkin, + deleteSkinComment, + listSkins, + skinCommentsFor, + updateSkin +} from "../../server/db/skins.js"; +import { audit, moderationSubjectAuditMetadata } from "../../server/db/moderation/index.js"; +import { notifySkinComment } from "../../server/db/notifications/index.js"; +import { addCommentFromForm, deleteCommentFromRoute } from "../../server/comments/actions.js"; +import { updateProfile } from "../../server/db/users.js"; +import { field } from "../../server/forms.js"; +import { badFormRequestMessage, localBack, requiredField, requiredUserText, routeId, verifiedActionForm, withFragment } from "../../server/http.js"; +import { canDeleteAsOwnerOrModerator, canModerateAuthor } from "../../server/moderation/guards.js"; +import { previewFromRows } from "../../server/pagination.js"; +import { limits } from "../../policy.js"; +import { isAdminUser } from "../../roles.js"; +import { isSkinColorGenerateIntent, skinColorPaletteFromHtml, skinStyleCodeFromColorForm } from "../../skins/colorPalette.js"; +import { sanitizeSkinHtml } from "../../server/security/html.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { SkinItem } from "../../models.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; +import { SkinDetailPage, SkinFormPage, SkinListPage, SkinPreviewPage, type SkinFormDraft } from "../../views/skins/index.js"; +import { profilePageData } from "../profile/profilePageData.js"; +import { skinCommentsPath, skinPath } from "../../paths.js"; + +export function registerSkinRoutes(app: Hono) { + app.get("/skins", (c) => { + const user = currentUser(c); + return c.html(); + }); + app.get("/skins/new", (c) => c.html()); + app.post("/skins/new", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "skin.create"); + if (isSkinColorGenerateIntent(form)) return skinFormGenerate(c, user, form); + try { + const fields = skinFields(form); + const id = createSkin(user.id, fields.title, fields.descriptionHtml, fields.codeHtml); + fields.automod.createReports({ subjectType: "skin", subjectId: id, authorId: user.id }); + return c.redirect(skinPath(id)); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return skinFormError(c, user, message, undefined, skinDraftFromForm(form)); + throw error; + } + }); + app.get("/s/:id/preview", (c) => { + const user = requireAuth(c); + const skin = viewableSkin(c); + const profile = { ...requireProfile(user.id), skinHtml: skin.codeHtml }; + return c.html( + + ); + }); + app.get("/s/:id/edit", (c) => { + const user = requireAuth(c); + const skin = requireSkin(routeId(c)); + requireSkinOwnerOrAdmin(user, skin); + return c.html(); + }); + app.post("/s/:id/edit", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "content.write"); + const skin = requireSkin(routeId(c)); + requireSkinOwnerOrAdmin(user, skin); + if (isSkinColorGenerateIntent(form)) return skinFormGenerate(c, user, form, skin); + try { + const fields = skinFields(form); + updateSkin(skin.authorId, skin.id, fields.title, fields.descriptionHtml, fields.codeHtml); + fields.automod.createReports({ subjectType: "skin", subjectId: skin.id, authorId: skin.authorId ?? user.id }); + return c.redirect(skinPath(skin)); + } catch (error) { + const message = badFormRequestMessage(error); + if (message) return skinFormError(c, user, message, skin, skinDraftFromForm(form)); + throw error; + } + }); + app.post("/s/:id/apply", async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "profile.write"); + const skin = viewableSkin(c); + updateProfile(user.id, { skinHtml: skin.codeHtml }); + return c.redirect(withFragment("/account/profile", "skin")); + }); + app.post("/s/:id/delete", async (c) => { + const user = requireAuth(c); + await verifiedActionForm(c, "content.write"); + const skin = requireSkin(routeId(c)); + if (!canDeleteSkin(user, skin)) throw new HTTPException(403, { message: "You cannot delete this skin." }); + const elevated = skin.authorId !== null && canModerateAuthor(user, skin.authorId) && user.id !== skin.authorId; + const auditMetadata = elevated ? moderationSubjectAuditMetadata("skin", skin.id) : {}; + deleteSkin(skin.id, user.id, elevated); + if (elevated) audit(user.id, "delete", "skin", skin.id, "", auditMetadata); + return c.redirect("/skins"); + }); + app.post("/s/:id/comments", async (c) => { + const user = requireAuth(c); + const form = await verifiedActionForm(c, "comment.create"); + const skin = viewableSkin(c); + const reply = Boolean(field(form, "parentId")); + return addCommentFromForm(c, user, { + form, + subjectType: "skin_comment", + redirect: (commentId) => + localBack(c, reply ? skinCommentsPath(skin) : skinPath(skin), { + avoid: reply ? [skinPath(skin)] : undefined, + fragment: anchors.comment(commentId) + }), + add: (textHtml, parentId) => addSkinComment(skin.id, user.id, textHtml, parentId, user), + afterAdd: notifySkinComment + }); + }); + app.post("/s/comments/:id/delete", (c) => + deleteCommentFromRoute(c, { subjectType: "skin_comment", delete: deleteSkinComment, fallback: "/skins", redirectFragment: anchors.comments }) + ); + app.get("/s/:id/comments", (c) => skinPage(c, true)); + app.get("/s/:id", (c) => skinPage(c)); +} + +function skinPage(c: AppContext, fullComments = false) { + const user = currentUser(c); + const skin = viewableSkin(c); + const commentsHref = skinCommentsPath(skin); + const commentRows = skinCommentsFor(skin.id, user, fullComments ? undefined : limits.commentsPage + 1); + const comments = fullComments ? { items: commentRows, hasMore: false } : previewFromRows(commentRows, limits.commentsPage); + return c.html( + + ); +} + +function viewableSkin(c: AppContext) { + const skin = requireSkin(routeId(c)); + if (skin.authorId !== null) visibleProfile(c, skin.authorId); + return skin; +} + +function requireSkinOwnerOrAdmin(user: CurrentUser, skin: SkinItem) { + if (skin.authorId === null) { + if (!isAdminUser(user)) throw new HTTPException(403, { message: "You cannot edit this skin." }); + return; + } + requireOwnerOrAdmin(user, skin.authorId, "You cannot edit this skin."); +} + +function canDeleteSkin(user: CurrentUser, skin: SkinItem) { + return skin.authorId === null + ? isAdminUser(user) + : canDeleteAsOwnerOrModerator(user, skin.authorId, [skin.authorId]); +} + +function skinFields(form: Record) { + const fields = { + title: requiredField(form, "title", limits.shortText, "Title is required."), + descriptionHtml: requiredUserText(form, "description", limits.userText, "Description is required."), + codeHtml: requiredSkinHtml(form) + }; + return { ...fields, automod: scanAutomodSubmission("skin", fields.title, fields.descriptionHtml, fields.codeHtml) }; +} + +function requiredSkinHtml(form: Record) { + const raw = field(form, "code").slice(0, limits.skinHtml); + if (!raw) throw new HTTPException(400, { message: "Skin HTML is required." }); + const html = sanitizeSkinHtml(raw); + if (!html.trim()) throw new HTTPException(400, { message: "Skin HTML must include allowed markup." }); + return html; +} + +function skinFormGenerate(c: AppContext, user: CurrentUser, form: Record, skin?: SkinItem) { + const palette = brandingSettings().palette; + const fallback = skinColorPaletteFromHtml(field(form, "code") || skin?.codeHtml || "", palette); + return c.html( + + ); +} + +function skinDraftFromForm(form: Record, code = field(form, "code")): SkinFormDraft { + return { + code: code.slice(0, limits.skinHtml), + description: field(form, "description").slice(0, limits.userText), + title: field(form, "title").slice(0, limits.shortText) + }; +} + +function skinFormError(c: AppContext, user: CurrentUser, message: string, skin?: SkinItem, draft?: SkinFormDraft) { + return c.html( + , + 400 + ); +} diff --git a/src/routes/staff/dashboardData.ts b/src/routes/staff/dashboardData.ts new file mode 100644 index 0000000..a003dde --- /dev/null +++ b/src/routes/staff/dashboardData.ts @@ -0,0 +1,29 @@ +import { listAutomodRules } from "../../server/db/automod.js"; +import { brandingSettings } from "../../server/db/branding.js"; +import { emailOutbox } from "../../server/db/email.js"; +import { auditLog, listReports, listResolvedReports } from "../../server/db/moderation/index.js"; +import { listRateLimitSettings, raidModeActive } from "../../server/db/rateLimits.js"; +import { siteSettings } from "../../server/db/siteSettings.js"; +import { databaseTableCounts, staffBlogRows, staffFavoriteEdges, staffUserRows } from "../../server/db/staffDashboard.js"; +import type { StaffUserRow } from "../../models.js"; +import type { StaffSection } from "../../views/staff/index.js"; + +export function staffDashboardData(section: StaffSection, users?: StaffUserRow[]) { + const show = (target: StaffSection) => section === "overview" || section === target; + const showReports = show("reports"); + return { + users: show("users") ? users ?? staffUserRows() : [], + reports: showReports ? listReports() : [], + resolvedReports: showReports ? listResolvedReports() : [], + automodRules: show("automod") ? listAutomodRules() : [], + counts: show("database") ? databaseTableCounts() : [], + blogs: show("blog") ? staffBlogRows() : [], + favorites: show("favorites") ? staffFavoriteEdges() : [], + outbox: show("email") ? emailOutbox() : [], + rateLimitSettings: show("rateLimits") ? listRateLimitSettings() : [], + rateLimitRaidActive: show("rateLimits") ? raidModeActive() : false, + colorTheme: show("siteSettings") ? brandingSettings() : null, + siteSettings: show("siteSettings") ? siteSettings() : null, + audit: show("audit") ? auditLog() : [] + }; +} diff --git a/src/routes/staff/reportForm.ts b/src/routes/staff/reportForm.ts new file mode 100644 index 0000000..175938d --- /dev/null +++ b/src/routes/staff/reportForm.ts @@ -0,0 +1,15 @@ +import { HTTPException } from "hono/http-exception"; +import { field } from "../../server/forms.js"; +import type { ReportAction } from "../../server/moderation/actions.js"; +import { limits } from "../../policy.js"; +import { sanitizeUserText } from "../../server/security/html.js"; + +export function reportActionFromValue(value: string): ReportAction { + if (value === "resolve" || value === "delete" || value === "ban_author" || value === "delete_and_ban") return value; + throw new HTTPException(400, { message: "Unknown report action." }); +} + +export function optionalReportNoteFromForm(form: Record) { + const note = field(form, "note").slice(0, limits.userText); + return note ? sanitizeUserText(note) : ""; +} diff --git a/src/routes/system/branding.ts b/src/routes/system/branding.ts new file mode 100644 index 0000000..b04097d --- /dev/null +++ b/src/routes/system/branding.ts @@ -0,0 +1,38 @@ +import type { Hono } from "hono"; +import { themeCssFromPalette } from "../../theme/themeCss.js"; +import { brandingSettings } from "../../server/db/branding.js"; +import { siteSettings } from "../../server/db/siteSettings.js"; +import { brandAssetFile, type BrandAssetName } from "../../server/branding/assets.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; + +export function registerBrandingRoutes(app: Hono) { + app.get("/branding.css", (c) => { + const branding = brandingSettings(); + c.header("Content-Type", "text/css; charset=utf-8"); + c.header("Cache-Control", "no-store"); + return c.body(branding.customized ? themeCssFromPalette(branding.palette) : ""); + }); + app.get("/favicon.svg", (c) => { + return brandAssetResponse(c, "favicon.svg"); + }); + app.get("/og-image.svg", (c) => { + return brandAssetResponse(c, "og-image.svg"); + }); + app.get("/og-image.png", (c) => brandAssetResponse(c, "og-image.png")); + app.get("/apple-touch-icon.png", (c) => brandAssetResponse(c, "apple-touch-icon.png")); + app.get("/icon-192.png", (c) => brandAssetResponse(c, "icon-192.png")); + app.get("/icon-512.png", (c) => brandAssetResponse(c, "icon-512.png")); + app.get("/icon-1024.png", (c) => brandAssetResponse(c, "icon-1024.png")); + app.get("/site.webmanifest", (c) => { + return brandAssetResponse(c, "site.webmanifest"); + }); +} + +async function brandAssetResponse(c: AppContext, filename: BrandAssetName) { + const branding = brandingSettings(); + const settings = siteSettings(); + const asset = await brandAssetFile(filename, settings, branding.palette); + c.header("Content-Type", asset.contentType); + c.header("Cache-Control", "no-store"); + return c.body(asset.content); +} diff --git a/src/routes/system/crawlers.ts b/src/routes/system/crawlers.ts new file mode 100644 index 0000000..67f4b46 --- /dev/null +++ b/src/routes/system/crawlers.ts @@ -0,0 +1,18 @@ +import type { Hono } from "hono"; +import { robotsText } from "../../server/indexing/crawlers.js"; +import { sitemapXml } from "../../server/indexing/sitemap.js"; +import type { AppBindings } from "../../server/context.js"; + +export function registerCrawlerRoutes(app: Hono) { + app.get("/robots.txt", (c) => { + c.header("Content-Type", "text/plain; charset=utf-8"); + c.header("Cache-Control", "no-store"); + return c.text(robotsText()); + }); + + app.get("/sitemap.xml", (c) => { + c.header("Content-Type", "application/xml; charset=utf-8"); + c.header("Cache-Control", "no-store"); + return c.body(sitemapXml()); + }); +} diff --git a/src/routes/system/index.ts b/src/routes/system/index.ts new file mode 100644 index 0000000..a0877df --- /dev/null +++ b/src/routes/system/index.ts @@ -0,0 +1,13 @@ +import type { Hono } from "hono"; +import type { AppBindings } from "../../server/context.js"; +import { registerBrandingRoutes } from "./branding.js"; +import { registerCrawlerRoutes } from "./crawlers.js"; +import { registerRefreshRoutes } from "./refresh.js"; +import { registerThemeRoutes } from "./theme.js"; + +export function registerSystemRoutes(app: Hono) { + registerCrawlerRoutes(app); + registerRefreshRoutes(app); + registerThemeRoutes(app); + registerBrandingRoutes(app); +} diff --git a/src/routes/system/media.ts b/src/routes/system/media.ts new file mode 100644 index 0000000..52082ba --- /dev/null +++ b/src/routes/system/media.ts @@ -0,0 +1,70 @@ +import { readFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { currentUser } from "../../server/auth/session.js"; +import { visibleProfile } from "../../server/access.js"; +import { profileByProfileImage, profileByThemeSong } from "../../server/db/users.js"; +import { getVisiblePost, postIdsForImage } from "../../server/db/posts/index.js"; +import { paths } from "../../server/env.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; + +const mediaContentTypes = new Map([ + [".webp", "image/webp"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".png", "image/png"], + [".gif", "image/gif"], + [".mp3", "audio/mpeg"], + [".ogg", "audio/ogg"] +]); + +export function registerMediaRoutes(app: Hono) { + app.get("/media/pfp/:filename", async (c) => { + const filename = mediaFilename(c); + const profile = profileByProfileImage(filename); + if (!profile) throw new HTTPException(404, { message: "Media not found." }); + assertMediaProfileVisible(c, profile.id); + return mediaFile(c, paths.pfp, filename); + }); + + app.get("/media/theme-songs/:filename", async (c) => { + const filename = mediaFilename(c); + const profile = profileByThemeSong(filename); + if (!profile) throw new HTTPException(404, { message: "Media not found." }); + assertMediaProfileVisible(c, profile.id); + return mediaFile(c, paths.themeSongs, filename); + }); + + app.get("/media/post-images/:filename", async (c) => { + const filename = mediaFilename(c); + const viewer = currentUser(c); + const visible = postIdsForImage(filename).some((id) => getVisiblePost(id, viewer)); + if (!visible) throw new HTTPException(404, { message: "Media not found." }); + return mediaFile(c, paths.postImages, filename); + }); +} + +function mediaFilename(c: AppContext) { + const filename = c.req.param("filename") ?? ""; + if (!filename || filename !== basename(filename)) throw new HTTPException(404, { message: "Media not found." }); + return filename; +} + +function assertMediaProfileVisible(c: AppContext, profileId: number) { + try { + visibleProfile(c, profileId); + } catch (error) { + if (error instanceof HTTPException) throw new HTTPException(404, { message: "Media not found." }); + throw error; + } +} + +async function mediaFile(c: AppContext, root: string, filename: string) { + const data = await readFile(join(root, filename)).catch(() => null); + if (!data) throw new HTTPException(404, { message: "Media not found." }); + + c.header("Cache-Control", "private, no-store"); + c.header("Content-Type", mediaContentTypes.get(extname(filename).toLowerCase()) ?? "application/octet-stream"); + return c.body(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); +} diff --git a/src/routes/system/refresh.ts b/src/routes/system/refresh.ts new file mode 100644 index 0000000..5f820ee --- /dev/null +++ b/src/routes/system/refresh.ts @@ -0,0 +1,7 @@ +import type { Hono } from "hono"; +import { localBack } from "../../server/http.js"; +import type { AppBindings } from "../../server/context.js"; + +export function registerRefreshRoutes(app: Hono) { + app.get("/refresh", (c) => c.redirect(localBack(c, "/"))); +} diff --git a/src/routes/system/theme.ts b/src/routes/system/theme.ts new file mode 100644 index 0000000..f71b0c5 --- /dev/null +++ b/src/routes/system/theme.ts @@ -0,0 +1,46 @@ +import type { Hono } from "hono"; +import { getCookie, setCookie } from "hono/cookie"; +import { env } from "../../server/env.js"; +import { localBack } from "../../server/http.js"; +import { themePreference } from "../../policy.js"; +import { brandingSettings } from "../../server/db/branding.js"; +import type { AppBindings, AppContext } from "../../server/context.js"; + +const darkThemeCss = '@import url("/static/css/themes/dark.css");\n'; + +function currentTheme(c: AppContext) { + return getCookie(c, themePreference.cookieName) === "dark" ? "dark" : "light"; +} + +function setNextTheme(c: AppContext) { + const nextTheme = safeCustomBrandingActive() ? "light" : currentTheme(c) === "dark" ? "light" : "dark"; + setCookie(c, themePreference.cookieName, nextTheme, { + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: env.secureCookies, + maxAge: themePreference.maxAgeSeconds + }); +} + +function safeCustomBrandingActive() { + try { + return brandingSettings().customized; + } catch { + return false; + } +} + +export function registerThemeRoutes(app: Hono) { + app.get("/theme", (c) => { + setNextTheme(c); + return c.redirect(localBack(c, "/")); + }); + + app.get("/theme.css", (c) => { + c.header("Content-Type", "text/css; charset=utf-8"); + c.header("Cache-Control", "private, no-store"); + c.header("Vary", "Cookie"); + return c.body(!safeCustomBrandingActive() && currentTheme(c) === "dark" ? darkThemeCss : ""); + }); +} diff --git a/src/scripts/generateBrandAssets.ts b/src/scripts/generateBrandAssets.ts new file mode 100644 index 0000000..c6f7586 --- /dev/null +++ b/src/scripts/generateBrandAssets.ts @@ -0,0 +1,24 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { brandAssetFiles, defaultBrandAssetDirectory } from "../server/branding/assets.js"; +import { defaultSiteSettings } from "../settings/site.js"; +import { defaultColorPalette } from "../theme/colorPalette.js"; + +const clean = process.argv.includes("--clean"); + +if (clean) { + rmSync(defaultBrandAssetDirectory, { force: true, recursive: true }); + console.log(`Removed ${defaultBrandAssetDirectory}`); +} else { + mkdirSync(defaultBrandAssetDirectory, { recursive: true }); + const files = await brandAssetFiles(defaultSiteSettings, defaultColorPalette); + + for (const file of files) { + const path = join(defaultBrandAssetDirectory, file.filename); + writeFileSync(path, file.content instanceof ArrayBuffer ? new Uint8Array(file.content) : file.content); + console.log(`Generated ${path}`); + } + + console.log(`Default brand assets are available at ${pathToFileURL(defaultBrandAssetDirectory).href}`); +} diff --git a/src/server/access.ts b/src/server/access.ts new file mode 100644 index 0000000..f04fd04 --- /dev/null +++ b/src/server/access.ts @@ -0,0 +1,93 @@ +import { HTTPException } from "hono/http-exception"; +import { currentUser } from "./auth/session.js"; +import { getBlog } from "./db/blogs/index.js"; +import { canViewGroup, getGroup } from "./db/groups.js"; +import { getSkin } from "./db/skins.js"; +import { friendshipBetween, isBlockedBetween } from "./db/relationships.js"; +import type { Friendship, UserProfile } from "../models.js"; +import { getProfile } from "./db/users.js"; +import { friendshipStatus } from "../policy.js"; +import { canBypassVisibility, canViewReports, isAdminUser } from "../roles.js"; +import type { CurrentUser } from "../currentUser.js"; +import type { AppContext } from "./context.js"; + +export function requireAuth(c: AppContext) { + const user = currentUser(c); + if (!user) throw new HTTPException(302, { res: c.redirect("/login") }); + return user; +} + +export function requireAdmin(c: AppContext) { + const user = requireAuth(c); + if (!isAdminUser(user)) throw new HTTPException(403, { message: "Admin access required." }); + return user; +} + +export function requireModerator(c: AppContext) { + const user = requireAuth(c); + if (!canViewReports(user)) throw new HTTPException(403, { message: "Moderation access required." }); + return user; +} + +function canManage(user: CurrentUser, ownerId: number) { + return user.id === ownerId || isAdminUser(user); +} + +export function requireOwnerOrAdmin(user: CurrentUser, ownerId: number, message: string) { + if (!canManage(user, ownerId)) throw new HTTPException(403, { message }); +} + +export function requireProfile(id: number) { + const profile = getProfile(id); + if (!profile) throw new HTTPException(404, { message: "User not found." }); + return profile; +} + +export function visibleProfile(c: AppContext, id: number) { + const profile = requireProfile(id); + const user = currentUser(c); + if (profile.bannedAt && !canBypassVisibility(user)) { + throw new HTTPException(404, { message: "User not found." }); + } + if (user && user.id !== id && !canBypassVisibility(user) && isBlockedBetween(user.id, id)) { + throw new HTTPException(403, { message: "This profile is unavailable." }); + } + const friendship = user ? friendshipBetween(user.id, id) : undefined; + if (!canViewProfile(user, profile, friendship)) { + throw new HTTPException(403, { message: "This profile is private." }); + } + return { profile, user, friendship }; +} + +function canViewProfile(user: CurrentUser | null, profile: UserProfile, friendship?: Friendship) { + if (profile.bannedAt && !canBypassVisibility(user)) return false; + if (!profile.private) return true; + if (!user) return false; + return user.id === profile.id || canBypassVisibility(user) || friendship?.status === friendshipStatus.accepted; +} + +export function requireBlog(id: number, viewer: CurrentUser | null = null) { + const blog = getBlog(id, viewer); + if (!blog) throw new HTTPException(404, { message: "Blog not found." }); + return blog; +} + +export function requireGroup(id: number) { + const group = getGroup(id); + if (!group) throw new HTTPException(404, { message: "Group not found." }); + return group; +} + +export function visibleGroup(c: AppContext, id: number) { + const group = requireGroup(id); + const user = requireAuth(c); + visibleProfile(c, group.ownerId); + if (!canViewGroup(user, group.id)) throw new HTTPException(404, { message: "Group not found." }); + return { group, user }; +} + +export function requireSkin(id: number) { + const skin = getSkin(id); + if (!skin) throw new HTTPException(404, { message: "Skin not found." }); + return skin; +} diff --git a/src/server/adminProtection.ts b/src/server/adminProtection.ts new file mode 100644 index 0000000..63854a2 --- /dev/null +++ b/src/server/adminProtection.ts @@ -0,0 +1,12 @@ +import { HTTPException } from "hono/http-exception"; +import { env } from "./env.js"; + +export function isProtectedAdminAccount(target: { id: number }) { + return target.id === env.adminUserId; +} + +export function assertMutableManagedUser(target: { id: number }) { + if (isProtectedAdminAccount(target)) { + throw new HTTPException(403, { message: "The protected admin account cannot be modified." }); + } +} diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts new file mode 100644 index 0000000..9ec83de --- /dev/null +++ b/src/server/auth/password.ts @@ -0,0 +1,14 @@ +import { hash, verify } from "@node-rs/argon2"; + +export async function hashPassword(password: string) { + return hash(password, { + memoryCost: 19456, + timeCost: 2, + parallelism: 1, + outputLen: 32 + }); +} + +export async function verifyPassword(hashValue: string, password: string) { + return verify(hashValue, password); +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts new file mode 100644 index 0000000..621b3b6 --- /dev/null +++ b/src/server/auth/session.ts @@ -0,0 +1,128 @@ +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import type { MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { sqlite } from "../db/client.js"; +import { env } from "../env.js"; +import { session } from "../../policy.js"; +import { normalizeTimeZone } from "../../timeZones.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppBindings, AppContext } from "../context.js"; +import { randomToken, safeEqual, sha256 } from "../security/crypto.js"; + +export const loadSession: MiddlewareHandler = async (c, next) => { + const token = getCookie(c, session.cookieName); + let user: CurrentUser | null = null; + let csrfToken = getCookie(c, session.csrfCookieName) ?? randomToken(session.csrfTokenBytes); + + if (token) { + const row = sqlite + .prepare( + `SELECT s.id AS sessionId, s.csrf_token AS csrfToken, s.expires_at AS expiresAt, + u.id, u.username, u.email, u.role, u.time_zone AS timeZone, u.verified_at AS verifiedAt, u.banned_at AS bannedAt + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ? AND s.revoked_at IS NULL AND u.banned_at IS NULL` + ) + .get(sha256(token)) as + | (CurrentUser & { + sessionId: number; + csrfToken: string; + expiresAt: string; + }) + | undefined; + + if (row && new Date(row.expiresAt).getTime() > Date.now()) { + user = { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + timeZone: normalizeTimeZone(row.timeZone), + verifiedAt: row.verifiedAt, + bannedAt: row.bannedAt + }; + csrfToken = row.csrfToken; + } else { + if (row) revokeSession(row.sessionId); + deleteCookie(c, session.cookieName, { path: "/" }); + } + } + + c.set("currentUser", user); + c.set("csrfToken", csrfToken); + setCookie(c, session.csrfCookieName, csrfToken, { + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: env.secureCookies, + maxAge: session.anonCsrfMaxAgeSeconds + }); + + await next(); +}; + +export function currentUser(c: AppContext) { + return c.get("currentUser"); +} + +export function csrfToken(c: AppContext) { + return c.get("csrfToken"); +} + +export function assertCsrf(c: AppContext, formToken: unknown) { + const expected = csrfToken(c); + if (typeof formToken !== "string" || !safeEqual(expected, formToken)) { + throw new HTTPException(403, { message: "Invalid CSRF token." }); + } +} + +export function createSession(c: AppContext, userId: number) { + const token = randomToken(session.tokenBytes); + const csrf = randomToken(session.csrfTokenBytes); + const expires = new Date(Date.now() + session.maxAgeSeconds * 1000).toISOString(); + sqlite.transaction(() => { + pruneUserSessions(userId); + sqlite + .prepare("INSERT INTO sessions (token_hash, csrf_token, user_id, expires_at) VALUES (?, ?, ?, ?)") + .run(sha256(token), csrf, userId, expires); + })(); + + setCookie(c, session.cookieName, token, { + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: env.secureCookies, + maxAge: session.maxAgeSeconds + }); +} + +export function revokeUserSessions(userId: number, exceptTokenHash?: string) { + if (exceptTokenHash) { + sqlite + .prepare("UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL AND token_hash <> ?") + .run(userId, exceptTokenHash); + return; + } + sqlite.prepare("UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId); +} + +export function revokeOtherSessions(c: AppContext, userId: number) { + const token = getCookie(c, session.cookieName); + revokeUserSessions(userId, token ? sha256(token) : undefined); +} + +export function destroySession(c: AppContext) { + const token = getCookie(c, session.cookieName); + if (token) { + sqlite.prepare("UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ?").run(sha256(token)); + } + deleteCookie(c, session.cookieName, { path: "/" }); + deleteCookie(c, session.csrfCookieName, { path: "/" }); +} + +function revokeSession(sessionId: number) { + sqlite.prepare("UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL").run(sessionId); +} + +function pruneUserSessions(userId: number) { + sqlite.prepare("DELETE FROM sessions WHERE user_id = ? AND (revoked_at IS NOT NULL OR datetime(expires_at) <= CURRENT_TIMESTAMP)").run(userId); +} diff --git a/src/server/branding/assets.ts b/src/server/branding/assets.ts new file mode 100644 index 0000000..3ce3c02 --- /dev/null +++ b/src/server/branding/assets.ts @@ -0,0 +1,84 @@ +import sharp from "sharp"; +import { defaultColorPalette, type ColorPalette } from "../../theme/colorPalette.js"; +import { siteAppIconSvg, siteFaviconSvg, defaultSiteSettings, type SiteSettings } from "../../settings/site.js"; +import { siteSocialPreviewSvg, siteWebManifest } from "../../settings/seo.js"; + +export const defaultBrandAssetDirectory = "public/static/brand"; + +export type BrandAssetName = + | "apple-touch-icon.png" + | "favicon.svg" + | "icon-192.png" + | "icon-1024.png" + | "icon-512.png" + | "og-image.png" + | "og-image.svg" + | "site.webmanifest"; + +type BrandAssetFile = { + content: ArrayBuffer | string; + contentType: string; + filename: BrandAssetName; +}; + +const brandAssetContentTypes = { + "apple-touch-icon.png": "image/png", + "favicon.svg": "image/svg+xml; charset=utf-8", + "icon-192.png": "image/png", + "icon-1024.png": "image/png", + "icon-512.png": "image/png", + "og-image.png": "image/png", + "og-image.svg": "image/svg+xml; charset=utf-8", + "site.webmanifest": "application/manifest+json; charset=utf-8" +} satisfies Record; + +const brandAssetNames = Object.keys(brandAssetContentTypes) as BrandAssetName[]; + +export async function brandAssetFile( + filename: BrandAssetName, + settings: SiteSettings = defaultSiteSettings, + palette: ColorPalette = defaultColorPalette +): Promise { + return { + content: await brandAssetContent(filename, settings, palette), + contentType: brandAssetContentTypes[filename], + filename + }; +} + +export function brandAssetFiles(settings: SiteSettings = defaultSiteSettings, palette: ColorPalette = defaultColorPalette) { + return Promise.all(brandAssetNames.map((filename) => brandAssetFile(filename, settings, palette))); +} + +async function brandAssetContent(filename: BrandAssetName, settings: SiteSettings, palette: ColorPalette) { + switch (filename) { + case "apple-touch-icon.png": + return pngFromSvg(siteAppIconSvg(settings, palette), 180); + case "favicon.svg": + return siteFaviconSvg(settings, palette); + case "icon-192.png": + return pngFromSvg(siteAppIconSvg(settings, palette), 192); + case "icon-1024.png": + return pngFromSvg(siteAppIconSvg(settings, palette), 1024); + case "icon-512.png": + return pngFromSvg(siteAppIconSvg(settings, palette), 512); + case "og-image.png": + return pngFromSvg(siteSocialPreviewSvg(settings, palette)); + case "og-image.svg": + return siteSocialPreviewSvg(settings, palette); + case "site.webmanifest": + return siteWebManifest(settings, palette); + } +} + +async function pngFromSvg(svg: string, size?: number): Promise { + const pipeline = sharp(Buffer.from(svg)); + const buffer = await (size ? pipeline.resize(size, size) : pipeline).png().toBuffer(); + return arrayBuffer(buffer); +} + +function arrayBuffer(buffer: Buffer): ArrayBuffer { + const bytes = new Uint8Array(buffer.byteLength); + bytes.set(buffer); + return bytes.buffer; +} diff --git a/src/server/comments/actions.ts b/src/server/comments/actions.ts new file mode 100644 index 0000000..f0b1aa8 --- /dev/null +++ b/src/server/comments/actions.ts @@ -0,0 +1,62 @@ +import { HTTPException } from "hono/http-exception"; +import { currentUser } from "../auth/session.js"; +import { scanAutomodSubmission } from "../db/automod.js"; +import { audit, auditSubjectMetadata } from "../db/moderation/index.js"; +import { getModerationSubject } from "../db/moderation/subjects.js"; +import { localBack, optionalFormId, requiredUserText, routeId, verifiedActionForm } from "../http.js"; +import { canModerateAuthor } from "../moderation/guards.js"; +import { limits, type ReportSubjectType } from "../../policy.js"; +import { isAdminUser } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { AppContext } from "../context.js"; + +type AddComment = (textHtml: string, parentId?: number) => number | null; +type DeleteComment = (commentId: number, actorId: number, privileged: boolean) => boolean; + +export async function addCommentFromForm( + c: AppContext, + user: CurrentUser, + input: { + add: AddComment; + afterAdd?: (commentId: number) => void; + form?: Record; + redirect: string | ((commentId: number) => string); + subjectType: ReportSubjectType; + } +) { + const form = input.form ?? (await verifiedActionForm(c, "comment.create")); + const textHtml = requiredUserText(form, "text", limits.userText, "Comment is required."); + const automod = scanAutomodSubmission("comment", textHtml); + + const commentId = input.add(textHtml, optionalFormId(form, "parentId")); + if (commentId === null) throw new HTTPException(400, { message: "You can only reply to top-level comments." }); + + input.afterAdd?.(commentId); + automod.createReports({ subjectType: input.subjectType, subjectId: commentId, authorId: user.id }); + return c.redirect(typeof input.redirect === "function" ? input.redirect(commentId) : input.redirect); +} + +export async function deleteCommentFromRoute( + c: AppContext, + input: { + delete: DeleteComment; + fallback: string; + redirectFragment?: string; + subjectType: ReportSubjectType; + } +) { + const user = currentUser(c); + if (!user) throw new HTTPException(302, { res: c.redirect("/login") }); + + await verifiedActionForm(c, "content.write"); + const commentId = routeId(c); + const subject = getModerationSubject(input.subjectType, commentId); + const elevated = canModerateAuthor(user, subject?.authorId) && user.id !== subject?.authorId; + + if (!input.delete(commentId, user.id, isAdminUser(user) || elevated)) { + throw new HTTPException(403, { message: "You cannot delete this comment." }); + } + + if (elevated) audit(user.id, "delete", input.subjectType, commentId, "", subject ? auditSubjectMetadata(subject) : {}); + return c.redirect(localBack(c, subject?.url ?? input.fallback, { fragment: input.redirectFragment })); +} diff --git a/src/server/context.ts b/src/server/context.ts new file mode 100644 index 0000000..9b0a8e0 --- /dev/null +++ b/src/server/context.ts @@ -0,0 +1,11 @@ +import type { Context } from "hono"; +import type { CurrentUser } from "../currentUser.js"; + +export type AppBindings = { + Variables: { + currentUser: CurrentUser | null; + csrfToken: string; + }; +}; + +export type AppContext = Context; diff --git a/src/server/db/account.ts b/src/server/db/account.ts new file mode 100644 index 0000000..2215bb6 --- /dev/null +++ b/src/server/db/account.ts @@ -0,0 +1,56 @@ +import { limits } from "../../policy.js"; +import { allBlogsForUser, blogCommentsFor, proppedBlogsForUser } from "./blogs/index.js"; +import { joinedGroupsForUser, ownedGroupsForUser } from "./groups.js"; +import { skinCommentsFor, skinsForUser } from "./skins.js"; +import { messagesForUser } from "./messages/index.js"; +import { reportsFiledByUser } from "./moderation/index.js"; +import { notificationPreferencesForUser, notificationsForUser } from "./notifications/index.js"; +import { + postCommentsByUser, + postCommentsFor, + postsAuthoredByUser, + postsOnWallForUser, + proppedPostsForUser +} from "./posts/index.js"; +import { blockedUsers, favoriteUsers, friendsFor, pendingRequestsFor, sentRequestsFor } from "./relationships.js"; +import { getCurrentUser, getProfile } from "./users.js"; + +export function exportAccountData(userId: number) { + const limit = limits.exportRows; + const viewer = getCurrentUser(userId) ?? null; + return { + profile: getProfile(userId), + friends: friendsFor(userId, limit), + receivedFriendRequests: pendingRequestsFor(userId, limit), + sentFriendRequests: sentRequestsFor(userId, limit), + favorites: favoriteUsers(userId, viewer, limit), + blockedUsers: blockedUsers(userId, limit), + postsAuthored: rowsWithComments(postsAuthoredByUser(userId, limit), (id) => postCommentsFor(id, viewer, limit)), + postsOnWall: rowsWithComments(postsOnWallForUser(userId, limit), (id) => postCommentsFor(id, viewer, limit)), + postComments: exportRows(postCommentsByUser(userId, limit)), + proppedPosts: exportRows(proppedPostsForUser(userId, limit)), + blogs: rowsWithComments(allBlogsForUser(userId, limit), (id) => blogCommentsFor(id, viewer, limit)), + proppedBlogs: exportRows(proppedBlogsForUser(userId, limit)), + groups: exportRows(ownedGroupsForUser(userId, limit)), + joinedGroups: exportRows(joinedGroupsForUser(userId, limit)), + messages: messagesForUser(userId, limit), + notificationPreferences: notificationPreferencesForUser(userId), + notifications: viewer ? notificationsForUser(viewer, { limit }).items : [], + reportsFiled: reportsFiledByUser(userId, limit), + skins: rowsWithComments(skinsForUser(userId, limit), (id) => skinCommentsFor(id, viewer, limit)) + }; +} + +function rowsWithComments( + rows: T[], + commentsFor: (id: number) => Array<{ authorSkinHtml?: unknown; authorRole?: unknown; ownerRole?: unknown }> +) { + return exportRows(rows).map((row) => ({ + ...row, + comments: exportRows(commentsFor(row.id)) + })); +} + +function exportRows(rows: T[]) { + return rows.map(({ authorRole: _authorRole, authorSkinHtml: _authorSkinHtml, ownerRole: _ownerRole, ...exported }) => exported); +} diff --git a/src/server/db/automod.test.ts b/src/server/db/automod.test.ts new file mode 100644 index 0000000..df39966 --- /dev/null +++ b/src/server/db/automod.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-automod-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("./client.js"); + db = client.sqlite; + const schema = await import("./schema.js"); + schema.initializeDatabase(); + + return { + automod: await import("./automod.js"), + blogs: await import("./blogs/index.js"), + moderation: await import("./moderation/index.js"), + users: await import("./users.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("automod rules", () => { + it("rejects matching submissions only in the configured scope", async () => { + const { automod, users } = await loadIsolatedDb(); + const adminId = users.createUser({ username: "Admin", email: "admin@example.test", handle: "admin-user", passwordHash: "hash" }); + + automod.saveAutomodRule({ + name: "Blocked phrase", + pattern: "badword", + patternType: "keyword", + scope: "blog", + action: "reject", + enabled: true, + actorId: adminId + }); + + expect(() => automod.scanAutomodSubmission("blog", "This includes BADWORD.")).toThrow(); + expect(() => automod.scanAutomodSubmission("comment", "This includes BADWORD.")).not.toThrow(); + }); + + it("matches common punctuation, leetspeak, and spacing bypasses", async () => { + const { automod, users } = await loadIsolatedDb(); + const adminId = users.createUser({ username: "Admin", email: "admin@example.test", handle: "admin-user", passwordHash: "hash" }); + + automod.saveAutomodRule({ + name: "Normalized keyword", + pattern: "badword", + patternType: "keyword", + scope: "all", + action: "reject", + enabled: true, + actorId: adminId + }); + automod.saveAutomodRule({ + name: "Tall glyph keyword", + pattern: "blockword", + patternType: "keyword", + scope: "all", + action: "reject", + enabled: true, + actorId: adminId + }); + + expect(() => automod.scanAutomodSubmission("post", "b @ d w 0 r d")).toThrow(); + expect(() => automod.scanAutomodSubmission("post", "BADword")).toThrow(); + expect(() => automod.scanAutomodSubmission("post", "b@d w0rd")).toThrow(); + expect(() => automod.scanAutomodSubmission("post", "b10ck w0rd")).toThrow(); + }); + + it("creates system reports for review matches", async () => { + const { automod, blogs, moderation, users } = await loadIsolatedDb(); + const adminId = users.createUser({ username: "Admin", email: "admin@example.test", handle: "admin-user", passwordHash: "hash" }); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const blogId = blogs.createBlog(authorId, "Title", "body"); + + automod.saveAutomodRule({ + name: "Regex review", + pattern: "bad\\s+phrase", + patternType: "regex", + scope: "all", + action: "review", + enabled: true, + actorId: adminId + }); + + automod.scanAutomodSubmission("blog", "A bad phrase appears here.").createReports({ + subjectType: "blog", + subjectId: blogId, + authorId + }); + + const report = moderation.listReports()[0]; + expect(report.reporterId).toBeNull(); + expect(report.subjectAuthorId).toBe(authorId); + expect(report.subjectType).toBe("blog"); + }); + + it("keeps disabled rules out of normal matching", async () => { + const { automod, users } = await loadIsolatedDb(); + const adminId = users.createUser({ username: "Admin", email: "admin@example.test", handle: "admin-user", passwordHash: "hash" }); + + automod.saveAutomodRule({ + name: "Disabled", + pattern: "hidden", + patternType: "keyword", + scope: "all", + action: "review", + enabled: false, + actorId: adminId + }); + + expect(() => automod.scanAutomodSubmission("blog", "hidden text")).not.toThrow(); + expect(automod.listAutomodRules().some((rule) => rule.name === "Disabled")).toBe(true); + }); +}); diff --git a/src/server/db/automod.ts b/src/server/db/automod.ts new file mode 100644 index 0000000..4ed6845 --- /dev/null +++ b/src/server/db/automod.ts @@ -0,0 +1,210 @@ +import { HTTPException } from "hono/http-exception"; +import { sqlite } from "./client.js"; +import { createReport } from "./moderation/index.js"; +import { + automodPatternMax, + automodScanMax, + isAutomodAction, + isAutomodPatternType, + isAutomodScope, + type AutomodAction, + type AutomodPatternType, + type AutomodScope +} from "../../automodPolicy.js"; +import { automodLiteralPattern, createAutomodScanText, type AutomodScanText } from "../moderation/automodNormalize.js"; +import { limits, type ReportSubjectType } from "../../policy.js"; +import { plainTextFromHtml, sanitizeUserText } from "../security/html.js"; +import type { AutomodMatch, AutomodRule } from "../../models.js"; + +type AutomodRuleInput = { + id?: number; + name: string; + pattern: string; + patternType: AutomodPatternType; + scope: AutomodScope; + action: AutomodAction; + enabled: boolean; + actorId: number; +}; + +type AutomodReportTarget = { + subjectType: ReportSubjectType; + subjectId: number; + authorId: number; +}; + +export type AutomodSubmissionScan = { + text: string; + createReports: (target: AutomodReportTarget) => void; +}; + +type CachedRulePatterns = { + key: string; + patterns: RegExp[]; +}; + +const ruleColumns = `r.id, r.name, r.pattern, r.pattern_type AS patternType, r.scope, r.action, + r.enabled, r.created_by AS createdBy, u.username AS createdByName, + r.created_at AS createdAt, r.updated_at AS updatedAt`; +const rulePatternCache = new Map(); + +export function listAutomodRules(limit = limits.exportRows) { + return sqlite + .prepare( + `SELECT ${ruleColumns} + FROM automod_rules r LEFT JOIN users u ON u.id = r.created_by + ORDER BY r.enabled DESC, r.updated_at DESC, r.id DESC LIMIT ?` + ) + .all(limit) as AutomodRule[]; +} + +export function getAutomodRule(ruleId: number) { + return sqlite + .prepare( + `SELECT ${ruleColumns} + FROM automod_rules r LEFT JOIN users u ON u.id = r.created_by + WHERE r.id = ? LIMIT 1` + ) + .get(ruleId) as AutomodRule | undefined; +} + +export function saveAutomodRule(input: AutomodRuleInput) { + const rule = { ...input, name: input.name.trim(), pattern: input.pattern.trim() }; + validateAutomodRule(rule); + if (rule.id) { + const info = sqlite + .prepare( + `UPDATE automod_rules + SET name = ?, pattern = ?, pattern_type = ?, scope = ?, action = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ) + .run(rule.name, rule.pattern, rule.patternType, rule.scope, rule.action, rule.enabled ? 1 : 0, rule.id); + if (!info.changes && !automodRuleExists(rule.id)) throw new HTTPException(404, { message: "Automod rule not found." }); + return rule.id; + } + + const info = sqlite + .prepare( + `INSERT INTO automod_rules (name, pattern, pattern_type, scope, action, enabled, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(rule.name, rule.pattern, rule.patternType, rule.scope, rule.action, rule.enabled ? 1 : 0, rule.actorId); + return Number(info.lastInsertRowid); +} + +export function setAutomodRuleEnabled(ruleId: number, enabled: boolean) { + const info = sqlite.prepare("UPDATE automod_rules SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(enabled ? 1 : 0, ruleId); + return info.changes > 0 || automodRuleExists(ruleId); +} + +export function deleteAutomodRule(ruleId: number) { + return sqlite.prepare("DELETE FROM automod_rules WHERE id = ?").run(ruleId).changes > 0; +} + +function automodMatches(scope: AutomodScope, text: string) { + const scanText = createAutomodScanText(normalizeScanText(text)); + if (!scanText.raw) return []; + const rules = sqlite + .prepare( + `SELECT ${ruleColumns} + FROM automod_rules r LEFT JOIN users u ON u.id = r.created_by + WHERE (r.scope = 'all' OR r.scope = ?) AND r.enabled = 1 + ORDER BY r.id ASC` + ) + .all(scope) as AutomodRule[]; + + return rules.filter((rule) => ruleMatches(rule, scanText)).map((rule) => ({ rule })) satisfies AutomodMatch[]; +} + +function assertAutomodAllowed(scope: AutomodScope, text: string) { + const reject = automodMatches(scope, text).find((match) => match.rule.action === "reject"); + if (reject) throw new HTTPException(400, { message: "This submission cannot be posted because it matches an automod rule." }); +} + +function createAutomodReports(input: { + subjectType: ReportSubjectType; + subjectId: number; + authorId: number; + scope: AutomodScope; + text: string; +}) { + const matches = automodMatches(input.scope, input.text).filter((match) => match.rule.action === "review"); + if (!matches.length) return; + const names = matches.map((match) => match.rule.name).join(", "); + const reason = sanitizeUserText(`Automod sent this ${input.subjectType} to review.\nRules: ${names}`); + createReport(null, input.subjectType, input.subjectId, reason, input.authorId); +} + +export function scanAutomodSubmission(scope: AutomodScope, ...parts: Array): AutomodSubmissionScan { + const text = automodText(...parts); + assertAutomodAllowed(scope, text); + return { + text, + createReports: (target: AutomodReportTarget) => createAutomodReports({ ...target, scope, text }) + }; +} + +function automodText(...parts: Array) { + return parts.map((part) => plainTextFromHtml(part ?? "")).filter(Boolean).join("\n"); +} + +export function requiredAutomodScope(value: string): AutomodScope { + if (isAutomodScope(value)) return value; + throw new HTTPException(400, { message: "Unknown automod scope." }); +} + +export function requiredAutomodPatternType(value: string): AutomodPatternType { + if (isAutomodPatternType(value)) return value; + throw new HTTPException(400, { message: "Unknown automod pattern type." }); +} + +export function requiredAutomodAction(value: string): AutomodAction { + if (isAutomodAction(value)) return value; + throw new HTTPException(400, { message: "Unknown automod action." }); +} + +function validateAutomodRule(input: AutomodRuleInput) { + if (!input.name.trim()) throw new HTTPException(400, { message: "Rule name is required." }); + if (!input.pattern.trim()) throw new HTTPException(400, { message: "Rule pattern is required." }); + if (input.pattern.length > automodPatternMax) throw new HTTPException(400, { message: `Rule pattern must be ${automodPatternMax} characters or fewer.` }); + if (!isAutomodScope(input.scope)) throw new HTTPException(400, { message: "Unknown automod scope." }); + if (!isAutomodPatternType(input.patternType)) throw new HTTPException(400, { message: "Unknown automod pattern type." }); + if (!isAutomodAction(input.action)) throw new HTTPException(400, { message: "Unknown automod action." }); + if (input.patternType === "regex") compileRegex(input.pattern); +} + +function automodRuleExists(ruleId: number) { + return Boolean(sqlite.prepare("SELECT 1 FROM automod_rules WHERE id = ?").get(ruleId)); +} + +function ruleMatches(rule: AutomodRule, text: AutomodScanText) { + return cachedRulePatterns(rule).some((pattern) => text.variants.some((variant) => pattern.test(variant))); +} + +function cachedRulePatterns(rule: AutomodRule) { + const key = `${rule.patternType}\0${rule.updatedAt}\0${rule.pattern}`; + const cached = rulePatternCache.get(rule.id); + if (cached?.key === key) return cached.patterns; + + const patterns = rule.patternType === "keyword" + ? keywordTerms(rule.pattern).map((term) => compileRegex(automodLiteralPattern(term))) + : [compileRegex(rule.pattern)]; + rulePatternCache.set(rule.id, { key, patterns }); + return patterns; +} + +function compileRegex(pattern: string) { + try { + return new RegExp(pattern, "iu"); + } catch { + throw new HTTPException(400, { message: "Regex pattern is invalid." }); + } +} + +function keywordTerms(pattern: string) { + return pattern.split(/\r?\n/).map((term) => term.trim()).filter(Boolean); +} + +function normalizeScanText(text: string) { + return text.replace(/\s+/g, " ").trim().slice(0, automodScanMax); +} diff --git a/src/server/db/blogs/comments.ts b/src/server/db/blogs/comments.ts new file mode 100644 index 0000000..9b93dd2 --- /dev/null +++ b/src/server/db/blogs/comments.ts @@ -0,0 +1,14 @@ +import { addComment, commentsFor, deleteComment } from "../comments.js"; +import type { CurrentUser } from "../../../currentUser.js"; + +export function blogCommentsFor(blogId: number, viewer: CurrentUser | null = null, limit?: number) { + return commentsFor("blog", blogId, { viewer, limit }); +} + +export function addBlogComment(blogId: number, authorId: number, textHtml: string, parentId?: number, viewer: CurrentUser | null = null): number | null { + return addComment("blog", blogId, authorId, textHtml, parentId, viewer); +} + +export function deleteBlogComment(commentId: number, actorId: number, isAdmin = false) { + return deleteComment("blog", commentId, actorId, isAdmin); +} diff --git a/src/server/db/blogs/index.test.ts b/src/server/db/blogs/index.test.ts new file mode 100644 index 0000000..3409362 --- /dev/null +++ b/src/server/db/blogs/index.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CurrentUser } from "../../../currentUser.js"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-db-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + process.env.BLIISH_ADMIN_USER_ID = "999999"; + + const client = await import("../client.js"); + db = client.sqlite; + const schema = await import("../schema.js"); + schema.initializeDatabase(); + + return { + comments: await import("../comments.js"), + blogs: await import("./index.js"), + relationships: await import("../relationships.js"), + users: await import("../users.js") + }; +} + +function currentUser(id: number): CurrentUser { + return { + id, + username: `user-${id}`, + email: `user-${id}@example.test`, + role: "user", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; + delete process.env.BLIISH_ADMIN_USER_ID; +}); + +describe("blog visibility", () => { + it("keeps private blogs out of public profile listings", async () => { + const { blogs, users } = await loadIsolatedDb(); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const viewerId = users.createUser({ username: "Viewer", email: "viewer@example.test", handle: "viewer", passwordHash: "hash" }); + const publicBlogId = blogs.createBlog(authorId, "Public", "body", "Life", 0); + + blogs.createBlog(authorId, "Friends", "body", "Life", 1); + blogs.createBlog(authorId, "Private", "body", "Life", 2); + + expect(blogs.blogsForUser(authorId, null, 10).map((blog) => blog.id)).toEqual([publicBlogId]); + expect(blogs.blogsForUser(authorId, currentUser(viewerId), 10).map((blog) => blog.id)).toEqual([ + publicBlogId + ]); + }); + + it("lists only propped blogs visible to the viewer", async () => { + const { blogs, relationships, users } = await loadIsolatedDb(); + const viewerId = users.createUser({ username: "Viewer", email: "viewer@example.test", handle: "viewer", passwordHash: "hash" }); + const friendId = users.createUser({ username: "Friend", email: "friend@example.test", handle: "friend", passwordHash: "hash" }); + const strangerId = users.createUser({ username: "Stranger", email: "stranger@example.test", handle: "stranger", passwordHash: "hash" }); + relationships.requestFriend(viewerId, friendId); + relationships.acceptFriend(viewerId, friendId); + + const publicBlogId = blogs.createBlog(strangerId, "Public", "body", "Life", 0); + const friendsBlogId = blogs.createBlog(friendId, "Friends", "body", "Life", 1); + const privateBlogId = blogs.createBlog(strangerId, "Private", "body", "Life", 2); + for (const blogId of [publicBlogId, friendsBlogId, privateBlogId]) { + blogs.addBlogProp(blogId, viewerId); + } + + expect(new Set(blogs.proppedBlogsForViewer(currentUser(viewerId), 10).map((blog) => blog.id))).toEqual( + new Set([friendsBlogId, publicBlogId]) + ); + + relationships.removeFriend(viewerId, friendId); + + expect(blogs.proppedBlogsForViewer(currentUser(viewerId), 10).map((blog) => blog.id)).toEqual([ + publicBlogId + ]); + }); + + it("hides blocked users from discovery and content listings", async () => { + const { blogs, relationships, users } = await loadIsolatedDb(); + const viewerId = users.createUser({ username: "Viewer", email: "viewer@example.test", handle: "viewer", passwordHash: "hash" }); + const blockedId = users.createUser({ username: "Blocked", email: "blocked@example.test", handle: "blocked", passwordHash: "hash" }); + const hiddenBlogId = blogs.createBlog(blockedId, "Hidden", "body"); + + relationships.blockUser(blockedId, viewerId); + + expect(users.listUsers(currentUser(viewerId)).map((person) => person.id)).not.toContain(blockedId); + expect(users.searchUsers("Blocked", currentUser(viewerId))).toEqual([]); + expect(blogs.allBlogs(currentUser(viewerId)).map((blog) => blog.id)).not.toContain(hiddenBlogId); + }); + + it("hides banned users from public content and comments", async () => { + const { blogs, comments, users } = await loadIsolatedDb(); + const activeId = users.createUser({ username: "Active", email: "active@example.test", handle: "active", passwordHash: "hash" }); + const bannedId = users.createUser({ username: "Banned", email: "banned@example.test", handle: "banned", passwordHash: "hash" }); + const activeBlogId = blogs.createBlog(activeId, "Active", "body"); + const bannedBlogId = blogs.createBlog(bannedId, "Banned", "body"); + const activeCommentId = comments.addComment("blog", activeBlogId, activeId, "active"); + const bannedCommentId = comments.addComment("blog", activeBlogId, bannedId, "banned"); + if (activeCommentId === null || bannedCommentId === null) { + throw new Error("Comments should be created before the ban."); + } + + users.setUserBanned(bannedId, true); + + expect(blogs.allBlogs(null, 10).map((blog) => blog.id)).toEqual([activeBlogId]); + expect(blogs.allBlogs(null, 10).map((blog) => blog.id)).not.toContain(bannedBlogId); + expect(blogs.searchBlogs("Banned", null, 10).blogs).toEqual([]); + expect(comments.commentsFor("blog", activeBlogId, { limit: 10 }).map((comment) => comment.id)).toEqual([ + activeCommentId + ]); + expect(comments.commentParentId("blog", bannedCommentId)).toBeUndefined(); + }); + + it("sorts discovery blog lists by visible props and comments before recency", async () => { + const { blogs, comments, users } = await loadIsolatedDb(); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const readerId = users.createUser({ username: "Reader", email: "reader@example.test", handle: "reader", passwordHash: "hash" }); + const secondReaderId = users.createUser({ username: "Second Reader", email: "reader2@example.test", handle: "reader2", passwordHash: "hash" }); + const newestId = blogs.createBlog(authorId, "Newest", "body", "Life"); + const discussedId = blogs.createBlog(authorId, "Discussed", "body", "Life"); + const popularId = blogs.createBlog(authorId, "Popular", "body", "Life"); + + db?.prepare("UPDATE blogs SET created_at = ? WHERE id = ?").run("2026-01-03 00:00:00", newestId); + db?.prepare("UPDATE blogs SET created_at = ? WHERE id = ?").run("2026-01-02 00:00:00", discussedId); + db?.prepare("UPDATE blogs SET created_at = ? WHERE id = ?").run("2026-01-01 00:00:00", popularId); + + blogs.addBlogProp(popularId, readerId); + comments.addComment("blog", popularId, readerId, "nice"); + comments.addComment("blog", discussedId, readerId, "first"); + comments.addComment("blog", discussedId, secondReaderId, "second"); + + const listed = blogs.allBlogs(null, 10); + expect(listed.map((blog) => blog.id)).toEqual([popularId, discussedId, newestId]); + expect(listed.map((blog) => blog.commentCount)).toEqual([1, 2, 0]); + expect(blogs.blogsByCategory("Life", null, 10).map((blog) => blog.id)).toEqual([popularId, discussedId, newestId]); + expect(blogs.blogsForUser(authorId, null, 10, "engagement").map((blog) => blog.id)).toEqual([popularId, discussedId, newestId]); + }); +}); diff --git a/src/server/db/blogs/index.ts b/src/server/db/blogs/index.ts new file mode 100644 index 0000000..baeeff8 --- /dev/null +++ b/src/server/db/blogs/index.ts @@ -0,0 +1,4 @@ +export * from "./comments.js"; +export * from "./mutations.js"; +export * from "./queries.js"; +export { canViewBlog } from "./visibility.js"; diff --git a/src/server/db/blogs/mutations.ts b/src/server/db/blogs/mutations.ts new file mode 100644 index 0000000..cfb5bf1 --- /dev/null +++ b/src/server/db/blogs/mutations.ts @@ -0,0 +1,43 @@ +import { defaultBlogCategory, type BlogCategory } from "../../../policy.js"; +import { sqlite } from "../client.js"; + +export function createBlog(authorId: number, title: string, bodyHtml: string, category: BlogCategory = defaultBlogCategory, privacyLevel = 0, commentsEnabled = true) { + const info = sqlite + .prepare("INSERT INTO blogs (author_id, title, body_html, category, privacy_level, comments_enabled) VALUES (?, ?, ?, ?, ?, ?)") + .run(authorId, title, bodyHtml, category, privacyLevel, commentsEnabled ? 1 : 0); + return Number(info.lastInsertRowid); +} + +export function updateBlog( + authorId: number, + blogId: number, + title: string, + bodyHtml: string, + category: BlogCategory = defaultBlogCategory, + privacyLevel = 0, + commentsEnabled = true, + pinned = false +) { + const info = sqlite + .prepare( + `UPDATE blogs SET title = ?, body_html = ?, category = ?, privacy_level = ?, comments_enabled = ?, + pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND author_id = ?` + ) + .run(title, bodyHtml, category, privacyLevel, commentsEnabled ? 1 : 0, pinned ? 1 : 0, blogId, authorId); + return info.changes > 0; +} + +export function deleteBlog(blogId: number, actorId: number, isAdmin = false) { + const info = isAdmin + ? sqlite.prepare("DELETE FROM blogs WHERE id = ?").run(blogId) + : sqlite.prepare("DELETE FROM blogs WHERE id = ? AND author_id = ?").run(blogId, actorId); + return info.changes > 0; +} + +export function addBlogProp(blogId: number, userId: number) { + return sqlite.prepare("INSERT OR IGNORE INTO blog_props (blog_id, user_id) VALUES (?, ?)").run(blogId, userId).changes > 0; +} + +export function removeBlogProp(blogId: number, userId: number) { + sqlite.prepare("DELETE FROM blog_props WHERE blog_id = ? AND user_id = ?").run(blogId, userId); +} diff --git a/src/server/db/blogs/queries.ts b/src/server/db/blogs/queries.ts new file mode 100644 index 0000000..840a365 --- /dev/null +++ b/src/server/db/blogs/queries.ts @@ -0,0 +1,114 @@ +import { limits, type BlogCategory } from "../../../policy.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { blogRows, blogPreviewRows } from "./sql.js"; +import { blogVisibilitySql } from "./visibility.js"; +import { profileVisibilitySql } from "../profileVisibility.js"; +import { containsLikePattern, likeEscapeClause } from "../like.js"; + +const blogDiscoveryOrder = `ORDER BY b.pinned DESC, (propsCount + commentCount) DESC, propsCount DESC, commentCount DESC, b.created_at DESC, b.id DESC`; +const blogLatestOrder = "ORDER BY b.pinned DESC, b.created_at DESC, b.id DESC"; +type BlogListOrder = "latest" | "engagement"; + +export function blogsForUser(userId: number, viewer: CurrentUser | null, limit = limits.profileBlogPreview, order: BlogListOrder = "latest") { + const visibility = blogVisibilitySql(viewer); + const orderBy = order === "engagement" ? blogDiscoveryOrder : blogLatestOrder; + return blogPreviewRows( + `WHERE b.author_id = ? AND ${visibility.sql} + ${orderBy} LIMIT ?`, + viewer, + userId, + ...visibility.params, + limit + ); +} + +export function allBlogsForUser(userId: number, limit = limits.exportRows) { + return blogPreviewRows("WHERE b.author_id = ? ORDER BY b.pinned DESC, b.created_at DESC LIMIT ?", exportViewer(userId), userId, limit); +} + +export function allBlogs(viewer: CurrentUser | null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + const visibility = blogVisibilitySql(viewer); + return blogRows( + `WHERE ${visible.sql} AND ${visibility.sql} + ${blogDiscoveryOrder} LIMIT ?`, + viewer, + ...visible.params, + ...visibility.params, + limit + ); +} + +export function blogsByCategory(category: BlogCategory, viewer: CurrentUser | null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + const visibility = blogVisibilitySql(viewer); + return blogRows( + `WHERE b.category = ? AND ${visible.sql} AND ${visibility.sql} + ${blogDiscoveryOrder} LIMIT ?`, + viewer, + category, + ...visible.params, + ...visibility.params, + limit + ); +} + +export function getBlog(id: number, viewer: CurrentUser | null = null) { + return blogRows("WHERE b.id = ? LIMIT 1", viewer, id)[0]; +} + +export function proppedBlogsForUser(userId: number, limit = limits.exportRows) { + const viewer = exportViewer(userId); + return blogRows( + `WHERE b.id IN (SELECT p.blog_id FROM blog_props p WHERE p.user_id = ?) + ORDER BY b.created_at DESC, b.id DESC LIMIT ?`, + viewer, + userId, + limit + ); +} + +export function proppedBlogsForViewer(viewer: CurrentUser, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + const visibility = blogVisibilitySql(viewer); + return blogRows( + `JOIN blog_props viewer_prop ON viewer_prop.blog_id = b.id AND viewer_prop.user_id = ? + WHERE ${visible.sql} AND ${visibility.sql} + ORDER BY viewer_prop.created_at DESC, b.id DESC LIMIT ?`, + viewer, + viewer.id, + ...visible.params, + ...visibility.params, + limit + ); +} + +export function searchBlogs(query: string, viewer: CurrentUser | null, limit = limits.listPage) { + const pattern = containsLikePattern(query); + const visible = profileVisibilitySql(viewer); + const visibility = blogVisibilitySql(viewer); + return { + blogs: blogRows( + `WHERE ${visible.sql} AND ${visibility.sql} AND (b.title LIKE ? ${likeEscapeClause} OR b.body_html LIKE ? ${likeEscapeClause}) + ORDER BY b.created_at DESC LIMIT ?`, + viewer, + ...visible.params, + ...visibility.params, + pattern, + pattern, + limit + ) + }; +} + +function exportViewer(userId: number): CurrentUser { + return { + id: userId, + username: "", + email: "", + role: "user", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + }; +} diff --git a/src/server/db/blogs/sql.ts b/src/server/db/blogs/sql.ts new file mode 100644 index 0000000..e11229b --- /dev/null +++ b/src/server/db/blogs/sql.ts @@ -0,0 +1,76 @@ +import type { CurrentUser } from "../../../currentUser.js"; +import { sqlite } from "../client.js"; +import { profileVisibilitySql } from "../profileVisibility.js"; +import type { BlogItem, BlogPreview } from "../../../models.js"; + +const blogCommentCountSql = (commentVisibilitySql: string, parentCommentVisibilitySql: string) => `( + SELECT COUNT(*) + FROM blog_comments comments + JOIN users comment_author ON comment_author.id = comments.author_id + JOIN profiles comment_profile ON comment_profile.user_id = comment_author.id + WHERE comments.blog_id = b.id + AND ${commentVisibilitySql} + AND ( + comments.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM blog_comments parent_comment + JOIN users parent_author ON parent_author.id = parent_comment.author_id + JOIN profiles parent_profile ON parent_profile.user_id = parent_author.id + WHERE parent_comment.id = comments.parent_id + AND parent_comment.blog_id = comments.blog_id + AND ${parentCommentVisibilitySql} + ) + ) + )`; + +const blogColumns = (propVisibilitySql: string, commentVisibilitySql: string, parentCommentVisibilitySql: string) => `b.id, b.title, b.body_html AS bodyHtml, b.created_at AS createdAt, b.updated_at AS updatedAt, + u.id AS authorId, u.role AS authorRole, p.handle AS authorHandle, u.username, b.category, b.privacy_level AS privacyLevel, + b.pinned, + ( + SELECT COUNT(*) + FROM blog_props props + JOIN users prop_user ON prop_user.id = props.user_id + JOIN profiles prop_profile ON prop_profile.user_id = prop_user.id + WHERE props.blog_id = b.id AND ${propVisibilitySql} + ) AS propsCount, + ${blogCommentCountSql(commentVisibilitySql, parentCommentVisibilitySql)} AS commentCount, + EXISTS(SELECT 1 FROM blog_props own_prop WHERE own_prop.blog_id = b.id AND own_prop.user_id = ?) AS proppedByViewer, + b.comments_enabled AS commentsEnabled`; + +const blogPreviewColumns = (propVisibilitySql: string, commentVisibilitySql: string, parentCommentVisibilitySql: string) => `b.id, b.title, b.body_html AS bodyHtml, b.created_at AS createdAt, b.updated_at AS updatedAt, + b.category, b.privacy_level AS privacyLevel, b.pinned, + ( + SELECT COUNT(*) + FROM blog_props props + JOIN users prop_user ON prop_user.id = props.user_id + JOIN profiles prop_profile ON prop_profile.user_id = prop_user.id + WHERE props.blog_id = b.id AND ${propVisibilitySql} + ) AS propsCount, + ${blogCommentCountSql(commentVisibilitySql, parentCommentVisibilitySql)} AS commentCount, + EXISTS(SELECT 1 FROM blog_props own_prop WHERE own_prop.blog_id = b.id AND own_prop.user_id = ?) AS proppedByViewer, + b.comments_enabled AS commentsEnabled`; + +const blogFrom = `FROM blogs b + JOIN users u ON u.id = b.author_id + JOIN profiles p ON p.user_id = u.id`; + +export function blogRows(tail: string, viewer: CurrentUser | null, ...params: unknown[]) { + const visibleProps = profileVisibilitySql(viewer, { user: "prop_user", profile: "prop_profile" }); + const visibleComments = profileVisibilitySql(viewer, { user: "comment_author", profile: "comment_profile" }); + const visibleParentComments = profileVisibilitySql(viewer, { user: "parent_author", profile: "parent_profile" }); + return sqlite + .prepare(`SELECT ${blogColumns(visibleProps.sql, visibleComments.sql, visibleParentComments.sql)} ${blogFrom} ${tail}`) + .all(...visibleProps.params, ...visibleComments.params, ...visibleParentComments.params, viewerId(viewer), ...params) as BlogItem[]; +} + +export function blogPreviewRows(tail: string, viewer: CurrentUser | null, ...params: unknown[]) { + const visibleProps = profileVisibilitySql(viewer, { user: "prop_user", profile: "prop_profile" }); + const visibleComments = profileVisibilitySql(viewer, { user: "comment_author", profile: "comment_profile" }); + const visibleParentComments = profileVisibilitySql(viewer, { user: "parent_author", profile: "parent_profile" }); + return sqlite + .prepare(`SELECT ${blogPreviewColumns(visibleProps.sql, visibleComments.sql, visibleParentComments.sql)} FROM blogs b ${tail}`) + .all(...visibleProps.params, ...visibleComments.params, ...visibleParentComments.params, viewerId(viewer), ...params) as BlogPreview[]; +} + +export function viewerId(viewer: CurrentUser | null) { + return viewer?.id ?? 0; +} diff --git a/src/server/db/blogs/visibility.ts b/src/server/db/blogs/visibility.ts new file mode 100644 index 0000000..1f6c415 --- /dev/null +++ b/src/server/db/blogs/visibility.ts @@ -0,0 +1,38 @@ +import { friendshipStatus } from "../../../policy.js"; +import { canBypassVisibility } from "../../../roles.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { sqlite } from "../client.js"; +import type { BlogItem } from "../../../models.js"; + +export function canViewBlog(viewer: CurrentUser | null, blog: BlogItem) { + if (blog.privacyLevel === 0 || canBypassVisibility(viewer)) return true; + if (!viewer) return false; + if (viewer.id === blog.authorId) return true; + if (blog.privacyLevel === 1) { + return Boolean( + sqlite + .prepare( + `SELECT 1 FROM friendships + WHERE ((sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)) AND status = ? + LIMIT 1` + ) + .get(viewer.id, blog.authorId, blog.authorId, viewer.id, friendshipStatus.accepted) + ); + } + return false; +} + +export function blogVisibilitySql(viewer: CurrentUser | null): { sql: string; params: unknown[] } { + if (!viewer) return { sql: "b.privacy_level = 0", params: [] }; + if (canBypassVisibility(viewer)) return { sql: "1 = 1", params: [] }; + return { + sql: `(b.privacy_level = 0 OR b.author_id = ? OR ( + b.privacy_level = 1 AND EXISTS ( + SELECT 1 FROM friendships f + WHERE ((f.sender_id = ? AND f.receiver_id = b.author_id) OR (f.receiver_id = ? AND f.sender_id = b.author_id)) + AND f.status = ? + ) + ))`, + params: [viewer.id, viewer.id, viewer.id, friendshipStatus.accepted] + }; +} diff --git a/src/server/db/branding.ts b/src/server/db/branding.ts new file mode 100644 index 0000000..7f58d2d --- /dev/null +++ b/src/server/db/branding.ts @@ -0,0 +1,39 @@ +import { + colorPaletteFromForm, + colorPaletteJson, + defaultColorPalette, + isDefaultColorPalette, + parseColorPaletteJson, + type ColorPalette +} from "../../theme/colorPalette.js"; +import type { BrandingSettings } from "../../settings/branding.js"; +import { deleteSetting, saveSetting, settingRow } from "./settings.js"; + +const brandingPaletteKey = "branding.palette"; + +export function brandingSettings(): BrandingSettings { + const row = settingRow(brandingPaletteKey); + const palette = parseColorPaletteJson(row?.value) ?? defaultColorPalette; + const customized = Boolean(row) && !isDefaultColorPalette(palette); + return { + customized, + palette, + updatedAt: customized ? row?.updatedAt ?? null : null + }; +} + +export function brandingPaletteFromForm(form: Record) { + return colorPaletteFromForm(form, brandingSettings().palette); +} + +export function saveBrandingPalette(palette: ColorPalette) { + if (isDefaultColorPalette(palette)) { + resetBrandingPalette(); + return; + } + saveSetting(brandingPaletteKey, colorPaletteJson(palette)); +} + +export function resetBrandingPalette() { + deleteSetting(brandingPaletteKey); +} diff --git a/src/server/db/client.ts b/src/server/db/client.ts new file mode 100644 index 0000000..49841e3 --- /dev/null +++ b/src/server/db/client.ts @@ -0,0 +1,12 @@ +import Database from "better-sqlite3"; +import { dirname } from "node:path"; +import { mkdirSync } from "node:fs"; +import { paths } from "../env.js"; +import { database } from "../../policy.js"; + +mkdirSync(dirname(paths.db), { recursive: true }); + +export const sqlite = new Database(paths.db); +sqlite.pragma("journal_mode = WAL"); +sqlite.pragma("foreign_keys = ON"); +sqlite.pragma(`busy_timeout = ${database.busyTimeoutMs}`); diff --git a/src/server/db/comments.ts b/src/server/db/comments.ts new file mode 100644 index 0000000..f66872c --- /dev/null +++ b/src/server/db/comments.ts @@ -0,0 +1,170 @@ +import { sqlite } from "./client.js"; +import { limits } from "../../policy.js"; +import { profileVisibilitySql } from "./profileVisibility.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { CommentItem } from "../../models.js"; + +const targets = { + blog: { + table: "blog_comments", + column: "blog_id", + ownerJoin: "JOIN blogs owner ON owner.id = c.blog_id", + ownerSelect: "owner.author_id AS ownerId" + }, + post: { + table: "post_comments", + column: "post_id", + ownerJoin: "JOIN posts owner ON owner.id = c.post_id LEFT JOIN groups g ON g.id = owner.group_id", + ownerSelect: "owner.author_id AS ownerId, owner.wall_user_id AS wallOwnerId, g.owner_id AS groupOwnerId" + }, + skin: { + table: "skin_comments", + column: "skin_id", + ownerJoin: "JOIN skins owner ON owner.id = c.skin_id", + ownerSelect: "owner.author_id AS ownerId" + } +} as const; + +export type CommentTarget = keyof typeof targets; +type CommentTable = (typeof targets)[CommentTarget]["table"]; + +type CommentOwnerRow = { + authorId: number; + groupOwnerId?: number | null; + ownerId: number | null; + wallOwnerId?: number | null; +}; + +type ReplyTargetRow = { + replyParentId: number | null; + targetParentId: number; +}; + +type CommentListOptions = { + limit?: number; + order?: "newest" | "oldest"; + viewer?: CurrentUser | null; +}; + +const commentColumns = `c.id, c.text_html AS textHtml, c.created_at AS createdAt, c.parent_id AS parentId, + u.id AS authorId, u.role AS authorRole, p.handle AS authorHandle, p.skin_html AS authorSkinHtml, u.username, p.pfp`; +const commentAuthorJoin = `JOIN users u ON u.id = c.author_id + JOIN profiles p ON p.user_id = u.id`; + +function commentRows(tail: string, ...params: unknown[]) { + return sqlite.prepare(`SELECT ${commentColumns} ${tail}`).all(...params) as CommentItem[]; +} + +export function commentRowsFrom(table: CommentTable, tail: string, ...params: unknown[]) { + return commentRows( + `FROM ${table} c + ${commentAuthorJoin} + ${tail}`, + ...params + ); +} + +export function commentsFor(target: CommentTarget, parentId: number, options: CommentListOptions = {}) { + const source = targets[target]; + const authorVisible = profileVisibilitySql(options.viewer ?? null); + const parentAuthorVisible = profileVisibilitySql(options.viewer ?? null, { user: "parent_author", profile: "parent_profile" }); + const threadOrder = options.order === "oldest" ? "ASC" : "DESC"; + return commentRowsFrom( + source.table, + `WHERE c.${source.column} = ? AND ${authorVisible.sql} + AND ( + c.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM ${source.table} parent_comment + JOIN users parent_author ON parent_author.id = parent_comment.author_id + JOIN profiles parent_profile ON parent_profile.user_id = parent_author.id + WHERE parent_comment.id = c.parent_id + AND parent_comment.${source.column} = c.${source.column} + AND ${parentAuthorVisible.sql} + ) + ) + ORDER BY COALESCE(c.parent_id, c.id) ${threadOrder}, c.parent_id IS NOT NULL ASC, c.created_at ASC, c.id ASC LIMIT ?`, + parentId, + ...authorVisible.params, + ...parentAuthorVisible.params, + options.limit ?? limits.exportRows + ); +} + +export function addComment( + target: CommentTarget, + parentId: number, + authorId: number, + textHtml: string, + replyTo?: number, + viewer: CurrentUser | null = null +): number | null { + const source = targets[target]; + const parentCommentId = topLevelReplyParentId(source, parentId, replyTo, viewer); + if (parentCommentId === undefined) return null; + const info = sqlite + .prepare(`INSERT INTO ${source.table} (${source.column}, author_id, text_html, parent_id) VALUES (?, ?, ?, ?)`) + .run(parentId, authorId, textHtml, parentCommentId); + return Number(info.lastInsertRowid); +} + +export function deleteComment(target: CommentTarget, commentId: number, actorId: number, isAdmin = false) { + const source = targets[target]; + const row = sqlite + .prepare( + `SELECT c.author_id AS authorId, ${source.ownerSelect} + FROM ${source.table} c ${source.ownerJoin} + WHERE c.id = ?` + ) + .get(commentId) as CommentOwnerRow | undefined; + if (!row || (!isAdmin && !commentOwnerIds(row).includes(actorId))) return false; + return sqlite.prepare(`DELETE FROM ${source.table} WHERE id = ?`).run(commentId).changes > 0; +} + +export function commentParentId(target: CommentTarget, commentId: number, viewer: CurrentUser | null = null) { + const source = targets[target]; + const visible = profileVisibilitySql(viewer); + const parentVisible = profileVisibilitySql(viewer, { user: "parent_author", profile: "parent_profile" }); + const row = sqlite + .prepare( + `SELECT c.${source.column} AS parentId FROM ${source.table} c + JOIN users u ON u.id = c.author_id + JOIN profiles p ON p.user_id = u.id + WHERE c.id = ? AND ${visible.sql} + AND ( + c.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM ${source.table} parent_comment + JOIN users parent_author ON parent_author.id = parent_comment.author_id + JOIN profiles parent_profile ON parent_profile.user_id = parent_author.id + WHERE parent_comment.id = c.parent_id + AND parent_comment.${source.column} = c.${source.column} + AND ${parentVisible.sql} + ) + )` + ) + .get(commentId, ...visible.params, ...parentVisible.params) as { parentId: number } | undefined; + return row?.parentId; +} + +function topLevelReplyParentId( + source: (typeof targets)[CommentTarget], + parentId: number, + replyTo: number | undefined, + viewer: CurrentUser | null +) { + if (replyTo === undefined) return null; + const visible = profileVisibilitySql(viewer); + const row = sqlite + .prepare( + `SELECT c.${source.column} AS targetParentId, c.parent_id AS replyParentId + FROM ${source.table} c + JOIN users u ON u.id = c.author_id + JOIN profiles p ON p.user_id = u.id + WHERE c.id = ? AND ${visible.sql}` + ) + .get(replyTo, ...visible.params) as ReplyTargetRow | undefined; + return row?.targetParentId === parentId && row.replyParentId === null ? replyTo : undefined; +} + +function commentOwnerIds(row: CommentOwnerRow) { + return [row.authorId, row.ownerId, row.wallOwnerId, row.groupOwnerId].filter((id): id is number => typeof id === "number"); +} diff --git a/src/server/db/email.ts b/src/server/db/email.ts new file mode 100644 index 0000000..565a179 --- /dev/null +++ b/src/server/db/email.ts @@ -0,0 +1,90 @@ +import { sqlite } from "./client.js"; +import { env } from "../env.js"; +import { session, limits } from "../../policy.js"; +import { randomToken, sha256 } from "../security/crypto.js"; +import { getCurrentUser, getUserByEmail } from "./users.js"; +import type { EmailOutboxItem } from "../../models.js"; +import { sendSmtpEmail, smtpConfigured } from "../email/smtp.js"; +import { siteSettings } from "./siteSettings.js"; + +const tokenBytes = session.tokenBytes; + +export async function requestPasswordReset(email: string) { + const user = getUserByEmail(email); + if (!user || user.bannedAt) return; + const token = issueToken("reset_tokens", user.id, "+2 hours"); + const siteName = siteSettings().identity.name; + await queueEmail(user.email, `${siteName} password reset`, `Open ${env.baseUrl}/reset/${token} to reset your password for ${siteName}. This link expires in 2 hours.`); +} + +export async function requestVerification(userId: number) { + const user = getCurrentUser(userId); + if (!user || user.verifiedAt || user.bannedAt) return; + const token = issueToken("verification_tokens", user.id, "+7 days"); + const siteName = siteSettings().identity.name; + await queueEmail(user.email, `Verify your ${siteName} account`, `Open ${env.baseUrl}/verify/${token} to verify your account for ${siteName}. This link expires in 7 days.`); +} + +export function consumeResetToken(token: string) { + return consumeToken("reset_tokens", token); +} + +export function consumeVerificationToken(token: string) { + return consumeToken("verification_tokens", token); +} + +export async function queueEmail(toEmail: string, subject: string, bodyText: string) { + const email = toEmail.slice(0, limits.emailMax); + const subjectText = subject.slice(0, limits.shortText); + const body = bodyText.slice(0, limits.userText); + const info = sqlite + .prepare("INSERT INTO email_outbox (to_email, subject, body_text, delivery_error) VALUES (?, ?, ?, NULL)") + .run(email, subjectText, body); + const id = Number(info.lastInsertRowid); + if (!smtpConfigured()) return { id, sent: false }; + try { + await sendSmtpEmail(email, subjectText, body); + sqlite + .prepare("UPDATE email_outbox SET sent_at = CURRENT_TIMESTAMP, delivery_error = NULL WHERE id = ?") + .run(id); + return { id, sent: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Email delivery failed."; + sqlite + .prepare("UPDATE email_outbox SET delivery_error = ? WHERE id = ?") + .run(message.slice(0, limits.userText), id); + return { id, sent: false, error: message }; + } +} + +export function emailOutbox(limit = limits.listPage) { + return sqlite + .prepare( + `SELECT id, to_email AS toEmail, subject, body_text AS bodyText, + sent_at AS sentAt, delivery_error AS deliveryError, created_at AS createdAt + FROM email_outbox ORDER BY created_at DESC LIMIT ?` + ) + .all(limit) as EmailOutboxItem[]; +} + +function consumeToken(table: "reset_tokens" | "verification_tokens", token: string) { + const hash = sha256(token); + return sqlite.transaction(() => { + const row = sqlite + .prepare(`SELECT user_id AS userId FROM ${table} WHERE token_hash = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP`) + .get(hash) as { userId: number } | undefined; + if (!row) return undefined; + const update = sqlite.prepare(`UPDATE ${table} SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND used_at IS NULL`).run(hash); + return update.changes > 0 ? row.userId : undefined; + })(); +} + +function issueToken(table: "reset_tokens" | "verification_tokens", userId: number, ttl: "+2 hours" | "+7 days") { + const token = randomToken(tokenBytes); + sqlite.transaction(() => { + sqlite.prepare(`DELETE FROM ${table} WHERE user_id = ? AND (used_at IS NOT NULL OR expires_at <= CURRENT_TIMESTAMP)`).run(userId); + sqlite.prepare(`UPDATE ${table} SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL`).run(userId); + sqlite.prepare(`INSERT INTO ${table} (token_hash, user_id, expires_at) VALUES (?, ?, datetime('now', ?))`).run(sha256(token), userId, ttl); + })(); + return token; +} diff --git a/src/server/db/groups.ts b/src/server/db/groups.ts new file mode 100644 index 0000000..f9e5b4d --- /dev/null +++ b/src/server/db/groups.ts @@ -0,0 +1,239 @@ +import { sqlite } from "./client.js"; +import { limits, systemIds } from "../../policy.js"; +import type { GroupItem, GroupMember } from "../../models.js"; +import { containsLikePattern, likeEscapeClause } from "./like.js"; +import { profileVisibilitySql } from "./profileVisibility.js"; +import { canBypassVisibility } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; + +const groupColumns = (memberVisibilitySql: string) => `g.id, g.name, g.description_html AS descriptionHtml, g.created_at AS createdAt, + u.id AS ownerId, u.role AS ownerRole, p.handle AS ownerHandle, u.username AS ownerName, + COUNT(CASE WHEN ${memberVisibilitySql} THEN active_member.id END) AS memberCount`; + +const groupSelect = (memberVisibilitySql: string) => `SELECT ${groupColumns(memberVisibilitySql)} + FROM groups g + JOIN users u ON u.id = g.owner_id + JOIN profiles p ON p.user_id = u.id + LEFT JOIN group_members m ON m.group_id = g.id + LEFT JOIN users active_member ON active_member.id = m.user_id + LEFT JOIN profiles active_member_profile ON active_member_profile.user_id = active_member.id`; + +export function listGroups(viewer: CurrentUser | null = null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + return groupRows(viewer, `WHERE ${visible.sql} GROUP BY g.id ORDER BY memberCount DESC, g.created_at DESC, g.id DESC LIMIT ?`, ...visible.params, limit); +} + +export function searchGroups(query: string, viewer: CurrentUser | null = null, limit = limits.listPage) { + const pattern = containsLikePattern(query); + const visible = profileVisibilitySql(viewer); + return groupRows( + viewer, + `WHERE ${visible.sql} AND (g.name LIKE ? ${likeEscapeClause} OR g.description_html LIKE ? ${likeEscapeClause}) + GROUP BY g.id ORDER BY g.created_at DESC LIMIT ?`, + ...visible.params, + pattern, + pattern, + limit + ); +} + +export function featuredCommunityGroups(limit = limits.newestCommunities) { + return sqlite + .prepare( + `${groupSelect("active_member.banned_at IS NULL")} + WHERE u.banned_at IS NULL + GROUP BY g.id + ORDER BY CASE WHEN g.id = ? THEN 0 ELSE 1 END, memberCount DESC, g.created_at DESC + LIMIT ?` + ) + .all(systemIds.defaultGroupId, limit) as GroupItem[]; +} + +export function getGroup(id: number) { + return groupRows(null, "WHERE g.id = ? AND u.banned_at IS NULL GROUP BY g.id", id)[0]; +} + +export function createGroup(ownerId: number, name: string, descriptionHtml: string) { + const create = sqlite.transaction(() => { + const info = sqlite.prepare("INSERT INTO groups (owner_id, name, description_html) VALUES (?, ?, ?)").run(ownerId, name, descriptionHtml); + const groupId = Number(info.lastInsertRowid); + sqlite.prepare("INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, 'owner')").run(groupId, ownerId); + sqlite.prepare("UPDATE profiles SET current_group_id = ? WHERE user_id = ?").run(groupId, ownerId); + return groupId; + }); + const groupId = create(); + if (isDefaultGroup(groupId)) ensureDefaultGroupMemberships(); + return groupId; +} + +export function joinGroup(groupId: number, userId: number) { + const join = sqlite.transaction(() => { + sqlite.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)").run(groupId, userId); + sqlite.prepare("UPDATE profiles SET current_group_id = ? WHERE user_id = ?").run(groupId, userId); + }); + join(); +} + +export function leaveGroup(groupId: number, userId: number) { + if (isDefaultGroup(groupId)) { + ensureDefaultGroupMembership(userId); + return false; + } + const leave = sqlite.transaction(() => { + const info = sqlite.prepare("DELETE FROM group_members WHERE group_id = ? AND user_id = ? AND role <> 'owner'").run(groupId, userId); + if (info.changes > 0) { + sqlite.prepare("UPDATE profiles SET current_group_id = NULL WHERE user_id = ? AND current_group_id = ?").run(userId, groupId); + } + return info.changes > 0; + }); + return leave(); +} + +export function updateGroup(ownerId: number, groupId: number, name: string, descriptionHtml: string) { + const info = sqlite + .prepare("UPDATE groups SET name = ?, description_html = ? WHERE id = ? AND owner_id = ?") + .run(name, descriptionHtml, groupId, ownerId); + return info.changes > 0; +} + +export function deleteGroup(groupId: number, actorId: number, isAdmin = false) { + if (isDefaultGroup(groupId)) return false; + const remove = sqlite.transaction(() => { + const info = isAdmin + ? sqlite.prepare("DELETE FROM groups WHERE id = ?").run(groupId) + : sqlite.prepare("DELETE FROM groups WHERE id = ? AND owner_id = ?").run(groupId, actorId); + if (info.changes > 0) { + sqlite.prepare("UPDATE profiles SET current_group_id = NULL WHERE current_group_id = ?").run(groupId); + } + return info.changes > 0; + }); + return remove(); +} + +export function groupMembers(groupId: number, viewer: CurrentUser | null = null, limit = limits.listPage) { + if (!canViewGroup(viewer, groupId)) return []; + const visible = profileVisibilitySql(viewer); + return sqlite + .prepare( + `SELECT u.id, u.username, p.handle, p.pfp, gm.role, gm.created_at AS joinedAt + FROM group_members gm + JOIN users u ON u.id = gm.user_id + JOIN profiles p ON p.user_id = u.id + WHERE gm.group_id = ? AND ${visible.sql} + ORDER BY gm.role = 'owner' DESC, gm.created_at ASC LIMIT ?` + ) + .all(groupId, ...visible.params, limit) as GroupMember[]; +} + +export function isGroupMember(groupId: number, userId: number) { + return Boolean(sqlite.prepare("SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?").get(groupId, userId)); +} + +export function canViewGroup(viewer: CurrentUser | null, groupId: number) { + if (!viewer) return false; + const visibleOwner = profileVisibilitySql(viewer); + return Boolean( + sqlite + .prepare( + `SELECT 1 FROM groups g + JOIN users u ON u.id = g.owner_id + JOIN profiles p ON p.user_id = u.id + WHERE g.id = ? AND ${visibleOwner.sql}` + ) + .get(groupId, ...visibleOwner.params) + ); +} + +export function groupPostAccessSql(viewer: CurrentUser | null, groupAlias = "g") { + if (!viewer) return { sql: "0 = 1", params: [] }; + if (canBypassVisibility(viewer)) return { sql: "1 = 1", params: [] }; + return { + sql: `EXISTS ( + SELECT 1 FROM group_members access_member + WHERE access_member.group_id = ${groupAlias}.id AND access_member.user_id = ? + )`, + params: [viewer.id] + }; +} + +export function ownedGroupsForUser(userId: number, limit = limits.exportRows) { + const viewer = exportViewer(userId); + return groupRows(viewer, "WHERE g.owner_id = ? AND u.banned_at IS NULL GROUP BY g.id ORDER BY g.created_at DESC LIMIT ?", userId, limit); +} + +export function joinedGroupsForUser(userId: number, limit = limits.exportRows) { + const visible = profileVisibilitySql(exportViewer(userId), { user: "active_member", profile: "active_member_profile" }); + return sqlite + .prepare( + `SELECT ${groupColumns(visible.sql)} + FROM group_members own_member + JOIN groups g ON g.id = own_member.group_id + JOIN users u ON u.id = g.owner_id + JOIN profiles p ON p.user_id = u.id + LEFT JOIN group_members all_members ON all_members.group_id = g.id + LEFT JOIN users active_member ON active_member.id = all_members.user_id + LEFT JOIN profiles active_member_profile ON active_member_profile.user_id = active_member.id + WHERE own_member.user_id = ? AND u.banned_at IS NULL + GROUP BY g.id + ORDER BY own_member.created_at DESC LIMIT ?` + ) + .all(...visible.params, userId, limit) as GroupItem[]; +} + +function isDefaultGroup(groupId: number) { + return groupId === systemIds.defaultGroupId; +} + +export function ensureDefaultGroupMembership(userId: number) { + sqlite.transaction(() => { + sqlite + .prepare( + `INSERT OR IGNORE INTO group_members (group_id, user_id, role) + SELECT g.id, u.id, CASE WHEN g.owner_id = u.id THEN 'owner' ELSE 'member' END + FROM groups g JOIN users u ON u.id = ? + WHERE g.id = ?` + ) + .run(userId, systemIds.defaultGroupId); + ensureDefaultGroupOwnerRole(); + })(); +} + +export function ensureDefaultGroupMemberships() { + sqlite.transaction(() => { + sqlite + .prepare( + `INSERT OR IGNORE INTO group_members (group_id, user_id, role) + SELECT g.id, u.id, CASE WHEN g.owner_id = u.id THEN 'owner' ELSE 'member' END + FROM groups g CROSS JOIN users u + WHERE g.id = ?` + ) + .run(systemIds.defaultGroupId); + ensureDefaultGroupOwnerRole(); + })(); +} + +function groupRows(viewer: CurrentUser | null, tail: string, ...params: unknown[]) { + const visibleMembers = profileVisibilitySql(viewer, { user: "active_member", profile: "active_member_profile" }); + return sqlite.prepare(`${groupSelect(visibleMembers.sql)} ${tail}`).all(...visibleMembers.params, ...params) as GroupItem[]; +} + +function ensureDefaultGroupOwnerRole() { + sqlite + .prepare( + `UPDATE group_members SET role = 'owner' + WHERE group_id = ? AND user_id = (SELECT owner_id FROM groups WHERE id = ?)` + ) + .run(systemIds.defaultGroupId, systemIds.defaultGroupId); +} + +function exportViewer(userId: number): CurrentUser { + return { + id: userId, + username: "", + email: "", + role: "user", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + }; +} diff --git a/src/server/db/indexing.ts b/src/server/db/indexing.ts new file mode 100644 index 0000000..f8b4b06 --- /dev/null +++ b/src/server/db/indexing.ts @@ -0,0 +1,104 @@ +import { isBlogCategory, limits } from "../../policy.js"; +import { blogPath, profilePath, skinPath } from "../../paths.js"; +import { sqlite } from "./client.js"; + +const sitemapLimit = 50_000; + +type HandleSitemapRow = { handle: string; lastmod: string }; +type IdSitemapRow = { id: number; lastmod: string }; +type CategoryRow = { category: string; lastmod: string }; + +export function publicProfileCanonicalPathByHandle(handle: string) { + const row = sqlite + .prepare( + `SELECT p.handle + FROM profiles p JOIN users u ON u.id = p.user_id + WHERE p.handle = ? AND u.banned_at IS NULL AND p.private = 0` + ) + .get(handle) as { handle: string } | undefined; + return row ? profilePath(row.handle) : null; +} + +export function publicBlogCanonicalPath(id: number) { + const row = sqlite + .prepare( + `SELECT b.id + FROM blogs b + JOIN users u ON u.id = b.author_id + JOIN profiles p ON p.user_id = u.id + WHERE b.id = ? AND b.privacy_level = 0 AND u.banned_at IS NULL AND p.private = 0` + ) + .get(id) as { id: number } | undefined; + return row ? blogPath(row) : null; +} + +export function publicSkinCanonicalPath(id: number) { + const row = sqlite + .prepare( + `SELECT s.id + FROM skins s + LEFT JOIN users u ON u.id = s.author_id + LEFT JOIN profiles p ON p.user_id = u.id + WHERE s.id = ? AND (s.source_key IS NOT NULL OR (u.banned_at IS NULL AND p.private = 0))` + ) + .get(id) as { id: number } | undefined; + return row ? skinPath(row) : null; +} + +export function publicProfileIndexPaths(limit = sitemapLimit) { + const rows = sqlite + .prepare( + `SELECT p.handle, max(u.created_at, u.updated_at) AS lastmod + FROM users u JOIN profiles p ON p.user_id = u.id + WHERE u.banned_at IS NULL AND p.private = 0 + ORDER BY u.created_at DESC, u.id DESC LIMIT ?` + ) + .all(limit) as HandleSitemapRow[]; + return rows.map((row) => ({ path: profilePath(row.handle), lastmod: row.lastmod })); +} + +export function publicBlogIndexPaths(limit = sitemapLimit) { + const rows = sqlite + .prepare( + `SELECT b.id, b.updated_at AS lastmod + FROM blogs b + JOIN users u ON u.id = b.author_id + JOIN profiles p ON p.user_id = u.id + WHERE b.privacy_level = 0 AND u.banned_at IS NULL AND p.private = 0 + ORDER BY b.created_at DESC, b.id DESC LIMIT ?` + ) + .all(limit) as IdSitemapRow[]; + return rows.map((row) => ({ path: blogPath(row), lastmod: row.lastmod })); +} + +export function publicSkinIndexPaths(limit = sitemapLimit) { + const rows = sqlite + .prepare( + `SELECT s.id, s.updated_at AS lastmod + FROM skins s + LEFT JOIN users u ON u.id = s.author_id + LEFT JOIN profiles p ON p.user_id = u.id + WHERE s.source_key IS NOT NULL OR (u.banned_at IS NULL AND p.private = 0) + ORDER BY s.updated_at DESC, s.id DESC LIMIT ?` + ) + .all(limit) as IdSitemapRow[]; + return rows.map((row) => ({ path: skinPath(row), lastmod: row.lastmod })); +} + +export function publicBlogCategoryIndexPaths(limit = limits.listPage) { + return ( + sqlite + .prepare( + `SELECT b.category, max(b.updated_at) AS lastmod + FROM blogs b + JOIN users u ON u.id = b.author_id + JOIN profiles p ON p.user_id = u.id + WHERE b.privacy_level = 0 AND u.banned_at IS NULL AND p.private = 0 + GROUP BY b.category + ORDER BY b.category ASC LIMIT ?` + ) + .all(limit) as CategoryRow[] + ) + .filter((row) => isBlogCategory(row.category)) + .map((row) => ({ path: `/blog/category/${encodeURIComponent(row.category)}`, lastmod: row.lastmod })); +} diff --git a/src/server/db/init.ts b/src/server/db/init.ts new file mode 100644 index 0000000..9fc92f1 --- /dev/null +++ b/src/server/db/init.ts @@ -0,0 +1,6 @@ +import { initializeDatabase } from "./schema.js"; +import { env } from "../env.js"; + +initializeDatabase(); + +console.log(`Initialized ${env.databasePath}`); diff --git a/src/server/db/like.ts b/src/server/db/like.ts new file mode 100644 index 0000000..70cabe6 --- /dev/null +++ b/src/server/db/like.ts @@ -0,0 +1,5 @@ +export const likeEscapeClause = "ESCAPE '\\'"; + +export function containsLikePattern(query: string) { + return `%${query.replace(/[\\%_]/g, "\\$&")}%`; +} diff --git a/src/server/db/messages/index.ts b/src/server/db/messages/index.ts new file mode 100644 index 0000000..90099d4 --- /dev/null +++ b/src/server/db/messages/index.ts @@ -0,0 +1,2 @@ +export * from "./mutations.js"; +export * from "./queries.js"; diff --git a/src/server/db/messages/mutations.ts b/src/server/db/messages/mutations.ts new file mode 100644 index 0000000..9831553 --- /dev/null +++ b/src/server/db/messages/mutations.ts @@ -0,0 +1,33 @@ +import { sqlite } from "../client.js"; +import { isBlockedBetween } from "../relationships.js"; + +export function markConversationRead(otherUserId: number, userId: number) { + sqlite + .prepare( + `UPDATE messages + SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) + WHERE sender_id = ? AND receiver_id = ? AND receiver_deleted = 0` + ) + .run(otherUserId, userId); +} + +export function sendMessage(senderId: number, receiverId: number, subject: string, bodyHtml: string) { + if (senderId === receiverId || isBlockedBetween(senderId, receiverId)) return false; + const info = sqlite + .prepare("INSERT INTO messages (sender_id, receiver_id, subject, body_html) VALUES (?, ?, ?, ?)") + .run(senderId, receiverId, subject, bodyHtml); + return Number(info.lastInsertRowid); +} + +export function deleteMessageFor(userId: number, messageId: number) { + return sqlite.transaction(() => { + const receiver = sqlite.prepare("UPDATE messages SET receiver_deleted = 1 WHERE id = ? AND receiver_id = ?").run(messageId, userId).changes; + const sender = sqlite.prepare("UPDATE messages SET sender_deleted = 1 WHERE id = ? AND sender_id = ?").run(messageId, userId).changes; + sqlite.prepare("DELETE FROM messages WHERE id = ? AND receiver_deleted = 1 AND sender_deleted = 1").run(messageId); + return receiver + sender > 0; + })(); +} + +export function deleteMessage(messageId: number) { + return sqlite.prepare("DELETE FROM messages WHERE id = ?").run(messageId).changes > 0; +} diff --git a/src/server/db/messages/queries.ts b/src/server/db/messages/queries.ts new file mode 100644 index 0000000..65bbc26 --- /dev/null +++ b/src/server/db/messages/queries.ts @@ -0,0 +1,112 @@ +import { + decodeKeysetCursor, + keysetBeforeCondition, + normalizePageLimit, + pageFromRows, + type PageOptions +} from "../../pagination.js"; +import { limits } from "../../../policy.js"; +import type { MessageConversation } from "../../../models.js"; +import { sqlite } from "../client.js"; +import { messageRows, visibleConversationParams, visibleMessageParams, visibleMessagesCte, visibleMessageSql } from "./sql.js"; + +type ConversationParticipantRow = { + handle: string; + id: number; + pfp: string; + username: string; +}; + +export function conversationsForUser(userId: number, options: PageOptions = {}) { + const limit = normalizePageLimit(options.limit, limits.listPage, limits.listPage); + const before = keysetBeforeCondition(decodeKeysetCursor(options.before), "ranked.created_at", "ranked.id"); + const rows = sqlite + .prepare( + `${visibleMessagesCte}, + ranked AS ( + SELECT visible_messages.*, + ROW_NUMBER() OVER (PARTITION BY other_id ORDER BY created_at DESC, id DESC) AS rn, + SUM(CASE WHEN receiver_id = ? AND read_at IS NULL AND receiver_deleted = 0 THEN 1 ELSE 0 END) OVER (PARTITION BY other_id) AS unreadCount + FROM visible_messages + ) + SELECT ranked.id, ranked.other_id AS otherUserId, other.username AS otherName, otherProfile.handle AS otherHandle, + otherProfile.pfp AS otherPfp, ranked.sender_id AS latestSenderId, + ranked.subject AS latestSubject, ranked.unreadCount, ranked.created_at AS createdAt + FROM ranked + JOIN users other ON other.id = ranked.other_id + JOIN profiles otherProfile ON otherProfile.user_id = other.id + WHERE ranked.rn = 1 + ${before.sql} + ORDER BY ranked.created_at DESC, ranked.id DESC LIMIT ?` + ) + .all(...visibleConversationParams(userId), userId, ...before.params, limit + 1) as MessageConversation[]; + return pageFromRows(rows, limit); +} + +export function messagesForConversation(userId: number, otherUserId: number, options: PageOptions = {}) { + const limit = normalizePageLimit(options.limit, limits.listPage, limits.listPage); + const before = keysetBeforeCondition(decodeKeysetCursor(options.before), "m.created_at", "m.id"); + return pageFromRows( + messageRows( + `WHERE ( + (m.sender_id = ? AND m.receiver_id = ? AND m.sender_deleted = 0) OR + (m.sender_id = ? AND m.receiver_id = ? AND m.receiver_deleted = 0) + ) + ${before.sql} + ORDER BY m.created_at DESC, m.id DESC LIMIT ?`, + userId, + otherUserId, + otherUserId, + userId, + ...before.params, + limit + 1 + ), + limit + ); +} + +export function conversationParticipantForHandle(userId: number, handle: string) { + return sqlite + .prepare( + `${visibleMessagesCte} + SELECT other.id, other.username, otherProfile.handle, otherProfile.pfp + FROM visible_messages m + JOIN users other ON other.id = m.other_id + JOIN profiles otherProfile ON otherProfile.user_id = other.id + WHERE otherProfile.handle = ? + ORDER BY m.created_at DESC, m.id DESC LIMIT 1` + ) + .get(...visibleConversationParams(userId), handle) as ConversationParticipantRow | undefined; +} + +export function messagesForUser(userId: number, limit = limits.exportRows) { + return messageRows( + `WHERE ${visibleMessageSql} + ORDER BY m.created_at DESC, m.id DESC LIMIT ?`, + ...visibleMessageParams(userId), + limit + ); +} + +export function unreadMessageCount(userId: number) { + return ( + sqlite + .prepare( + `SELECT COUNT(*) AS count + FROM messages + WHERE receiver_id = ? AND receiver_deleted = 0 AND read_at IS NULL` + ) + .get(userId) as { count: number } + ).count; +} + +export function canViewMessage(userId: number, messageId: number) { + return Boolean( + sqlite + .prepare( + `SELECT 1 FROM messages m + WHERE m.id = ? AND ${visibleMessageSql}` + ) + .get(messageId, ...visibleMessageParams(userId)) + ); +} diff --git a/src/server/db/messages/sql.ts b/src/server/db/messages/sql.ts new file mode 100644 index 0000000..a4f23f9 --- /dev/null +++ b/src/server/db/messages/sql.ts @@ -0,0 +1,33 @@ +import type { MessageItem } from "../../../models.js"; +import { sqlite } from "../client.js"; + +const messageColumns = `m.id, m.sender_id AS senderId, sender.username AS senderName, senderProfile.handle AS senderHandle, + senderProfile.pfp AS senderPfp, + m.receiver_id AS receiverId, receiver.username AS receiverName, receiverProfile.handle AS receiverHandle, m.subject, m.body_html AS bodyHtml, + m.read_at AS readAt, m.created_at AS createdAt`; + +const messageFrom = `FROM messages m + JOIN users sender ON sender.id = m.sender_id + JOIN profiles senderProfile ON senderProfile.user_id = sender.id + JOIN users receiver ON receiver.id = m.receiver_id + JOIN profiles receiverProfile ON receiverProfile.user_id = receiver.id`; + +export const visibleMessageSql = `((m.sender_id = ? AND m.sender_deleted = 0) OR (m.receiver_id = ? AND m.receiver_deleted = 0))`; + +export const visibleMessagesCte = `WITH visible_messages AS ( + SELECT m.*, CASE WHEN m.sender_id = ? THEN m.receiver_id ELSE m.sender_id END AS other_id + FROM messages m + WHERE ${visibleMessageSql} +)`; + +export function messageRows(tail: string, ...params: unknown[]) { + return sqlite.prepare(`SELECT ${messageColumns} ${messageFrom} ${tail}`).all(...params) as MessageItem[]; +} + +export function visibleMessageParams(userId: number) { + return [userId, userId] as const; +} + +export function visibleConversationParams(userId: number) { + return [userId, ...visibleMessageParams(userId)] as const; +} diff --git a/src/server/db/moderation/audit.ts b/src/server/db/moderation/audit.ts new file mode 100644 index 0000000..96d5437 --- /dev/null +++ b/src/server/db/moderation/audit.ts @@ -0,0 +1,215 @@ +import { limits, type ReportSubjectType } from "../../../policy.js"; +import { profilePath } from "../../../paths.js"; +import { auditActionLabel, isAuditSubjectType, subjectTypeLabel, type AuditSubjectType } from "../../../auditLabels.js"; +import { truncateText } from "../../../text.js"; +import { recordFromUnknown } from "../../../values.js"; +import { sqlite } from "../client.js"; +import type { AuditItem } from "../../../models.js"; +import { plainTextFromHtml } from "../../security/html.js"; +import { getModerationSubject, type ModerationSubject } from "./subjects.js"; +import { getReport } from "./reports.js"; + +type AuditSubjectFields = Pick< + AuditItem, + "subjectLabel" | "subjectUrl" | "subjectSummary" | "subjectMissing" | "subjectAuthorId" | "subjectAuthorName" | "subjectAuthorHandle" +>; +type AuditRow = Omit; +type MetadataRecord = Record; + +const unresolvableSubjectTypes = new Set(["app_setting", "rate_limit"]); + +export function audit(actorId: number | null, action: string, subjectType: AuditSubjectType, subjectId: number, reasonHtml = "", metadata: Record = {}) { + sqlite + .prepare("INSERT INTO audit_log (actor_id, action, subject_type, subject_id, reason_html, metadata_json) VALUES (?, ?, ?, ?, ?, ?)") + .run(actorId, action, subjectType, subjectId, reasonHtml, JSON.stringify(metadata)); +} + +export function auditSubjectMetadata(subject: ModerationSubject): Record { + return { + subjectLabel: auditModerationSubjectLabel(subject), + subjectSummary: subject.summary, + subjectAuthorId: subject.authorId, + subjectAuthorName: subject.authorName, + subjectAuthorHandle: subject.authorHandle + }; +} + +export function moderationSubjectAuditMetadata(subjectType: ReportSubjectType, subjectId: number): Record { + const subject = getModerationSubject(subjectType, subjectId); + return subject ? auditSubjectMetadata(subject) : {}; +} + +export function auditLog(limit = limits.listPage) { + const rows = sqlite + .prepare( + `SELECT a.id, a.actor_id AS actorId, u.username AS actorName, p.handle AS actorHandle, a.action, + a.subject_type AS subjectType, a.subject_id AS subjectId, a.reason_html AS reasonHtml, + a.metadata_json AS metadataJson, a.created_at AS createdAt + FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id + LEFT JOIN profiles p ON p.user_id = u.id + ORDER BY a.created_at DESC LIMIT ?` + ) + .all(limit) as AuditRow[]; + return rows.map(hydrateAuditRow); +} + +function hydrateAuditRow(row: AuditRow): AuditItem { + return { + ...row, + ...(specialAuditSubject(row) ?? moderationAuditSubject(row) ?? metadataAuditSubject(row) ?? defaultAuditSubject(row)) + } satisfies AuditItem; +} + +function specialAuditSubject(row: AuditRow): AuditSubjectFields | undefined { + if (row.subjectType === "app_setting") return subjectFields(appSettingLabel(row.action), "/admin/branding"); + if (row.subjectType === "rate_limit") return subjectFields("Rate limits", "/admin/rate-limits"); + if (row.subjectType === "automod_rule") return automodRuleSubject(row.subjectId); + if (row.subjectType === "email") return emailSubject(row.subjectId); + if (row.subjectType === "favorite") return profileSubject(row.subjectId, "Favorite"); + if (row.subjectType === "report") return reportSubject(row.subjectId); + return undefined; +} + +function moderationAuditSubject(row: AuditRow): AuditSubjectFields | undefined { + const subject = getModerationSubject(row.subjectType, row.subjectId); + if (!subject) return undefined; + return subjectFields( + auditModerationSubjectLabel(subject), + subject.url, + subject.summary, + false, + subject.authorId, + subject.authorName, + subject.authorHandle + ); +} + +function automodRuleSubject(ruleId: number): AuditSubjectFields | undefined { + const row = sqlite.prepare("SELECT name FROM automod_rules WHERE id = ?").get(ruleId) as { name: string } | undefined; + if (!row) return undefined; + return subjectFields(`Automod rule: ${truncateLabel(row.name)}`, `/admin/automod?open=${ruleId}#automod-rule-${ruleId}`); +} + +function emailSubject(emailId: number): AuditSubjectFields | undefined { + if (emailId <= 0) return subjectFields("Email outbox", "/admin/email"); + const row = sqlite + .prepare("SELECT to_email AS toEmail, subject FROM email_outbox WHERE id = ?") + .get(emailId) as { subject: string; toEmail: string } | undefined; + if (!row) return undefined; + return subjectFields(`Email: ${truncateLabel(row.subject)}`, "/admin/email", `To ${row.toEmail}`); +} + +function profileSubject(userId: number, label: string): AuditSubjectFields | undefined { + const row = sqlite + .prepare("SELECT u.username, p.handle FROM users u JOIN profiles p ON p.user_id = u.id WHERE u.id = ?") + .get(userId) as { handle: string; username: string } | undefined; + if (!row) return undefined; + return subjectFields(`${label}: ${row.username}`, profilePath(row), "", false, userId, row.username, row.handle); +} + +function reportSubject(reportId: number): AuditSubjectFields | undefined { + const report = getReport(reportId); + if (!report) return undefined; + const reason = plainTextFromHtml(report.reasonHtml); + const summary = [report.subjectSummary, reason ? `Reason: ${reason}` : ""].filter(Boolean).join(" "); + return subjectFields( + `Report: ${truncateLabel(report.subjectLabel)}`, + "/admin/reports", + summary, + false, + report.subjectAuthorId, + report.subjectAuthorName, + report.subjectAuthorHandle + ); +} + +function metadataAuditSubject(row: AuditRow): AuditSubjectFields | undefined { + const metadata = metadataRecord(row.metadataJson); + const label = metadataString(metadata.subjectLabel); + const summary = metadataString(metadata.subjectSummary) ?? ""; + if (!label && !summary) return undefined; + return subjectFields( + label ?? defaultSubjectLabel(row), + null, + summary, + true, + metadataNumber(metadata.subjectAuthorId), + metadataString(metadata.subjectAuthorName) ?? null, + metadataString(metadata.subjectAuthorHandle) ?? null + ); +} + +function defaultAuditSubject(row: AuditRow): AuditSubjectFields { + return subjectFields(defaultSubjectLabel(row), null, "", isResolvableSubjectType(row.subjectType)); +} + +function isResolvableSubjectType(subjectType: string) { + return isAuditSubjectType(subjectType) && !unresolvableSubjectTypes.has(subjectType); +} + +function auditModerationSubjectLabel(subject: ModerationSubject) { + if (subject.type === "user") return subject.authorName ?? `Profile #${subject.id}`; + const titled = titledSubjectLabel(subject); + const summary = titled || subject.summary; + const text = summary ? truncateLabel(summary) : subject.label; + return `${subjectTypeLabel(subject.type)}: ${text}`; +} + +function titledSubjectLabel(subject: ModerationSubject) { + const prefix = `${subjectTypeLabel(subject.type)}: `; + if (!subject.label.startsWith(prefix)) return ""; + return subject.label.slice(prefix.length).replace(/\s+#\d+$/, ""); +} + +function appSettingLabel(action: string) { + if (action === "reset_color_theme" || action === "update_color_theme") return "Color theme"; + if (action === "update_site_identity") return "Site identity"; + if (action === "update_site_home") return "Home page settings"; + if (action === "update_site_contact") return "Contact settings"; + return `Site setting: ${auditActionLabel(action)}`; +} + +function defaultSubjectLabel(row: AuditRow) { + const label = subjectTypeLabel(row.subjectType); + return row.subjectId > 0 ? `${label} #${row.subjectId}` : label; +} + +function subjectFields( + subjectLabel: string, + subjectUrl: string | null, + subjectSummary = "", + subjectMissing = false, + subjectAuthorId: number | null = null, + subjectAuthorName: string | null = null, + subjectAuthorHandle: string | null = null +): AuditSubjectFields { + return { + subjectLabel, + subjectUrl, + subjectSummary, + subjectMissing, + subjectAuthorId, + subjectAuthorName, + subjectAuthorHandle + }; +} + +function metadataRecord(json: string): MetadataRecord { + try { + return recordFromUnknown(JSON.parse(json)); + } catch { + return {}; + } +} + +function metadataString(value: unknown) { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function metadataNumber(value: unknown) { + return typeof value === "number" && Number.isSafeInteger(value) ? value : null; +} + +function truncateLabel(value: string) { + return truncateText(value, 72); +} diff --git a/src/server/db/moderation/index.ts b/src/server/db/moderation/index.ts new file mode 100644 index 0000000..8cce1b4 --- /dev/null +++ b/src/server/db/moderation/index.ts @@ -0,0 +1,2 @@ +export * from "./audit.js"; +export * from "./reports.js"; diff --git a/src/server/db/moderation/reports.ts b/src/server/db/moderation/reports.ts new file mode 100644 index 0000000..bfaaea0 --- /dev/null +++ b/src/server/db/moderation/reports.ts @@ -0,0 +1,65 @@ +import { limits, type ReportSubjectType } from "../../../policy.js"; +import { sqlite } from "../client.js"; +import type { ReportItem } from "../../../models.js"; +import { getModerationSubject } from "./subjects.js"; + +type ReportRow = Omit; + +const reportSelect = `SELECT r.id, r.reporter_id AS reporterId, reporter.username AS reporterName, reporterProfile.handle AS reporterHandle, + r.subject_author_id AS subjectAuthorId, author.username AS subjectAuthorName, authorProfile.handle AS subjectAuthorHandle, + r.subject_type AS subjectType, r.subject_id AS subjectId, r.reason_html AS reasonHtml, + r.created_at AS createdAt, r.resolved_at AS resolvedAt, resolver.username AS resolvedByName + FROM reports r + LEFT JOIN users reporter ON reporter.id = r.reporter_id + LEFT JOIN profiles reporterProfile ON reporterProfile.user_id = reporter.id + LEFT JOIN users author ON author.id = r.subject_author_id + LEFT JOIN profiles authorProfile ON authorProfile.user_id = author.id + LEFT JOIN users resolver ON resolver.id = r.resolved_by`; + +export function createReport(reporterId: number | null, subjectType: ReportSubjectType, subjectId: number, reasonHtml: string, subjectAuthorId?: number | null) { + sqlite + .prepare("INSERT INTO reports (reporter_id, subject_author_id, subject_type, subject_id, reason_html) VALUES (?, ?, ?, ?, ?)") + .run(reporterId, subjectAuthorId ?? null, subjectType, subjectId, reasonHtml); +} + +export function listReports(includeResolved = false, limit = limits.listPage) { + const filter = includeResolved ? "" : "WHERE r.resolved_at IS NULL"; + return hydrateReports(reportRows(`${filter} ORDER BY r.created_at DESC LIMIT ?`, limit)); +} + +export function listResolvedReports(limit = limits.listPage) { + return hydrateReports(reportRows("WHERE r.resolved_at IS NOT NULL ORDER BY r.resolved_at DESC, r.created_at DESC LIMIT ?", limit)); +} + +export function getReport(reportId: number) { + return hydrateReports(reportRows("WHERE r.id = ? LIMIT 1", reportId))[0]; +} + +export function reportsFiledByUser(userId: number, limit = limits.exportRows) { + return hydrateReports(reportRows("WHERE r.reporter_id = ? ORDER BY r.created_at DESC LIMIT ?", userId, limit)); +} + +export function resolveReport(reportId: number, actorId?: number) { + sqlite.prepare("UPDATE reports SET resolved_at = CURRENT_TIMESTAMP, resolved_by = COALESCE(?, resolved_by) WHERE id = ?").run(actorId ?? null, reportId); +} + +function hydrateReports(reports: ReportRow[]) { + return reports.map((report) => { + const subject = getModerationSubject(report.subjectType, report.subjectId); + return { + ...report, + subjectAuthorId: subject?.authorId ?? report.subjectAuthorId, + subjectAuthorName: subject?.authorName ?? report.subjectAuthorName, + subjectAuthorHandle: subject?.authorHandle ?? report.subjectAuthorHandle, + subjectLabel: subject?.label ?? `${report.subjectType} #${report.subjectId}`, + subjectUrl: subject?.url ?? null, + subjectSummary: subject?.summary ?? "", + subjectMissing: !subject, + subjectCanDelete: Boolean(subject?.canDelete) + } satisfies ReportItem; + }); +} + +function reportRows(tail: string, ...params: unknown[]) { + return sqlite.prepare(`${reportSelect} ${tail}`).all(...params) as ReportRow[]; +} diff --git a/src/server/db/moderation/subjectDelete.ts b/src/server/db/moderation/subjectDelete.ts new file mode 100644 index 0000000..201aebb --- /dev/null +++ b/src/server/db/moderation/subjectDelete.ts @@ -0,0 +1,40 @@ +import { deletePostImage, deletePostImages } from "../../media/upload.js"; +import { deleteComment } from "../comments.js"; +import { deleteBlog } from "../blogs/index.js"; +import { deleteGroup } from "../groups.js"; +import { deleteMessage } from "../messages/index.js"; +import { deletePost, postImageFilenamesForGroup } from "../posts/index.js"; +import { deleteSkin } from "../skins.js"; +import { commentSourceFor } from "./subjectSources.js"; +import type { DeletableReportSubjectType, ReportSubjectType } from "../../../policy.js"; + +type SubjectDeleter = (id: number, actorId: number) => boolean | Promise; + +const subjectDeleters = { + blog: (id: number, actorId: number) => deleteBlog(id, actorId, true), + group: async (id: number, actorId: number) => { + const postImages = postImageFilenamesForGroup(id); + const deleted = deleteGroup(id, actorId, true); + if (deleted) await deletePostImages(postImages); + return deleted; + }, + skin: (id: number, actorId: number) => deleteSkin(id, actorId, true), + message: (id: number) => deleteMessage(id), + post: async (id: number, actorId: number) => { + const media = deletePost(id, actorId, true); + if (media === false) return false; + await deletePostImage(media); + return true; + } +} satisfies Record; + +export async function deleteModerationSubject(type: ReportSubjectType, id: number, actorId: number) { + const commentTarget = commentSourceFor(type)?.target; + if (commentTarget) return deleteComment(commentTarget, id, actorId, true); + + return isDeletableSubjectType(type) ? await subjectDeleters[type](id, actorId) : false; +} + +function isDeletableSubjectType(type: ReportSubjectType): type is DeletableReportSubjectType { + return type in subjectDeleters; +} diff --git a/src/server/db/moderation/subjectSources.ts b/src/server/db/moderation/subjectSources.ts new file mode 100644 index 0000000..6c64412 --- /dev/null +++ b/src/server/db/moderation/subjectSources.ts @@ -0,0 +1,164 @@ +import { sqlite } from "../client.js"; +import { anchors } from "../../../anchors.js"; +import type { CommentReportSubjectType, ContentReportSubjectType } from "../../../policy.js"; +import type { CommentTarget } from "../comments.js"; +import { blogCommentsPath, blogPath, groupPath, postPath, profilePath, skinCommentsPath, skinPath } from "../../../paths.js"; + +export type SubjectRow = { + title?: string | null; + bodyHtml?: string | null; + url?: string | null; + authorId: number | null; + authorName: string | null; + authorHandle: string | null; + parentId?: number; + receiverName?: string | null; +}; + +type CommentSource = { + target: CommentTarget; + label: string; + url: (parentId: number, commentId: number) => string; + row: (id: number) => SubjectRow | undefined; +}; + +type SubjectSource = { + label: string; + canDelete: boolean; + row: (id: number) => SubjectRow | undefined; +}; + +const commentSources = { + blog_comment: { + target: "blog", + label: "Blog comment", + url: (parentId: number, commentId: number) => `${blogCommentsPath(parentId)}#${anchors.comment(commentId)}`, + row: (id: number) => commentRow("blog_comments", "blog_id", id) + }, + post_comment: { + target: "post", + label: "Post comment", + url: (parentId: number, commentId: number) => `${postPath(parentId)}#${anchors.comment(commentId)}`, + row: (id: number) => commentRow("post_comments", "post_id", id) + }, + skin_comment: { + target: "skin", + label: "Skin comment", + url: (parentId: number, commentId: number) => `${skinCommentsPath(parentId)}#${anchors.comment(commentId)}`, + row: (id: number) => commentRow("skin_comments", "skin_id", id) + } +} as const satisfies Record; + +const subjectSources = { + post: { + label: "Post", + canDelete: true, + row: (id: number) => row( + `SELECT po.body_html AS bodyHtml, ? AS url, u.id AS authorId, u.username AS authorName, p.handle AS authorHandle + FROM posts po + JOIN users u ON u.id = po.author_id + JOIN profiles p ON p.user_id = u.id + WHERE po.id = ?`, + postPath(id), + id + ) + }, + user: { + label: "User", + canDelete: false, + row: (id: number) => { + const subject = row( + `SELECT u.id AS authorId, u.username AS authorName, p.handle AS authorHandle, p.bio_html AS bodyHtml + FROM users u JOIN profiles p ON p.user_id = u.id WHERE u.id = ?`, + id + ); + return subject?.authorHandle ? { ...subject, url: profilePath(subject.authorHandle) } : subject; + } + }, + blog: { + label: "Blog entry", + canDelete: true, + row: (id: number) => row( + `SELECT b.title, b.body_html AS bodyHtml, ? AS url, u.id AS authorId, u.username AS authorName, p.handle AS authorHandle + FROM blogs b + JOIN users u ON u.id = b.author_id + JOIN profiles p ON p.user_id = u.id + WHERE b.id = ?`, + blogPath(id), + id + ) + }, + group: { + label: "Group", + canDelete: true, + row: (id: number) => row( + `SELECT g.name AS title, g.description_html AS bodyHtml, ? AS url, u.id AS authorId, u.username AS authorName, p.handle AS authorHandle + FROM groups g + JOIN users u ON u.id = g.owner_id + JOIN profiles p ON p.user_id = u.id + WHERE g.id = ?`, + groupPath(id), + id + ) + }, + skin: { + label: "Skin", + canDelete: true, + row: (id: number) => row( + `SELECT s.title, s.description_html AS bodyHtml, ? AS url, u.id AS authorId, + CASE WHEN s.source_key IS NOT NULL THEN 'Bliish.space' ELSE u.username END AS authorName, + p.handle AS authorHandle + FROM skins s + LEFT JOIN users u ON u.id = s.author_id + LEFT JOIN profiles p ON p.user_id = u.id + WHERE s.id = ?`, + skinPath(id), + id + ) + }, + message: { + label: "Private message", + canDelete: true, + row: (id: number) => row( + `SELECT m.subject AS title, m.body_html AS bodyHtml, NULL AS url, + sender.id AS authorId, sender.username AS authorName, senderProfile.handle AS authorHandle, receiver.username AS receiverName + FROM messages m + JOIN users sender ON sender.id = m.sender_id + JOIN profiles senderProfile ON senderProfile.user_id = sender.id + JOIN users receiver ON receiver.id = m.receiver_id + WHERE m.id = ?`, + id + ) + } +} satisfies Record; + +export function commentSourceFor(type: string) { + return isCommentSourceType(type) ? commentSources[type] : undefined; +} + +export function subjectSourceFor(type: string) { + return isSubjectSourceType(type) ? subjectSources[type] : undefined; +} + +function isCommentSourceType(type: string): type is keyof typeof commentSources { + return Object.hasOwn(commentSources, type); +} + +function isSubjectSourceType(type: string): type is keyof typeof subjectSources { + return Object.hasOwn(subjectSources, type); +} + +function commentRow(table: string, parentColumn: string, id: number) { + return row( + `SELECT c.text_html AS bodyHtml, c.${parentColumn} AS parentId, u.id AS authorId, u.username AS authorName, p.handle AS authorHandle + FROM ${table} c + JOIN users u ON u.id = c.author_id + JOIN profiles p ON p.user_id = u.id + WHERE c.id = ?`, + id + ); +} + +function row(sql: string, ...params: unknown[]) { + return sqlite.prepare(sql).get(...params) as SubjectRow | undefined; +} diff --git a/src/server/db/moderation/subjects.ts b/src/server/db/moderation/subjects.ts new file mode 100644 index 0000000..a273553 --- /dev/null +++ b/src/server/db/moderation/subjects.ts @@ -0,0 +1,59 @@ +import { plainTextFromHtml } from "../../security/html.js"; +import { truncateText } from "../../../text.js"; +import { commentSourceFor, subjectSourceFor, type SubjectRow } from "./subjectSources.js"; + +export { deleteModerationSubject } from "./subjectDelete.js"; + +export type ModerationSubject = { + type: string; + id: number; + label: string; + url: string | null; + authorId: number | null; + authorName: string | null; + authorHandle: string | null; + summary: string; + canDelete: boolean; +}; + +export function commentTargetForSubject(type: string) { + return commentSourceFor(type)?.target; +} + +export function getModerationSubject(type: string, id: number): ModerationSubject | undefined { + const comment = commentSubject(type, id); + if (comment) return comment; + const source = subjectSourceFor(type); + return source ? subject(type, id, source.label, source.row(id), source.canDelete) : undefined; +} + +function commentSubject(type: string, id: number) { + const source = commentSourceFor(type); + if (!source) return undefined; + const data = source.row(id); + return subject(type, id, source.label, data ? { ...data, url: source.url(Number(data.parentId), id) } : undefined, true); +} + +function subject(type: string, id: number, label: string, data: SubjectRow | undefined, canDelete: boolean) { + if (!data) return undefined; + const title = data.title ? `${label}: ${data.title}` : label; + const summary = data.receiverName + ? `${summaryText(data)} To ${data.receiverName}.` + : summaryText(data); + return { + type, + id, + label: `${title} #${id}`, + url: data.url ?? null, + authorId: data.authorId, + authorName: data.authorName, + authorHandle: data.authorHandle, + summary, + canDelete + } satisfies ModerationSubject; +} + +function summaryText(data: SubjectRow) { + const parts = [data.title ?? "", plainTextFromHtml(data.bodyHtml ?? "")].filter(Boolean).join(": "); + return truncateText(parts, 240); +} diff --git a/src/server/db/notifications/comments.ts b/src/server/db/notifications/comments.ts new file mode 100644 index 0000000..9e5360e --- /dev/null +++ b/src/server/db/notifications/comments.ts @@ -0,0 +1,140 @@ +import { + notificationKinds, + notificationPreferenceTypes, + type NotificationKind, + type NotificationPreferenceType +} from "../../../notifications.js"; +import { sqlite } from "../client.js"; +import { createNotifications, type NotificationTargetInput } from "./write.js"; + +type CommentEventRow = { + actorId: number; + contextId: number; + ownerId: number | null; + parentAuthorId: number | null; + wallOwnerId?: number | null; +}; + +const commentConfigs = { + blog: { + table: "blog_comments", + column: "blog_id", + ownerJoin: "JOIN blogs owner ON owner.id = c.blog_id", + ownerSelect: "owner.author_id AS ownerId", + contextType: "blog", + subjectType: "blog_comment", + ownerKind: notificationKinds.blogComment, + followedKind: notificationKinds.blogCommentFollowed, + replyKind: notificationKinds.blogCommentReply + }, + post: { + table: "post_comments", + column: "post_id", + ownerJoin: "JOIN posts owner ON owner.id = c.post_id", + ownerSelect: "owner.author_id AS ownerId, owner.wall_user_id AS wallOwnerId", + contextType: "post", + subjectType: "post_comment", + ownerKind: notificationKinds.postComment, + followedKind: notificationKinds.postCommentFollowed, + replyKind: notificationKinds.postCommentReply + }, + skin: { + table: "skin_comments", + column: "skin_id", + ownerJoin: "JOIN skins owner ON owner.id = c.skin_id", + ownerSelect: "owner.author_id AS ownerId", + contextType: "skin", + subjectType: "skin_comment", + ownerKind: notificationKinds.skinComment, + followedKind: notificationKinds.skinCommentFollowed, + replyKind: notificationKinds.skinCommentReply + } +} as const; + +type CommentNotificationTarget = keyof typeof commentConfigs; +type CommentConfig = (typeof commentConfigs)[CommentNotificationTarget]; + +export function notifyPostComment(commentId: number) { + return notifyComment("post", commentId); +} + +export function notifyBlogComment(commentId: number) { + return notifyComment("blog", commentId); +} + +export function notifySkinComment(commentId: number) { + return notifyComment("skin", commentId); +} + +function notifyComment(target: CommentNotificationTarget, commentId: number) { + const config = commentConfigs[target]; + const row = commentEventRow(config, commentId); + if (!row) return false; + + const excluded = new Set([row.actorId]); + const notifications: NotificationTargetInput[] = []; + if (row.parentAuthorId && row.parentAuthorId !== row.actorId) { + notifications.push(commentNotification(row.parentAuthorId, row, config, config.replyKind, commentId, notificationPreferenceTypes.comments)); + excluded.add(row.parentAuthorId); + } + for (const ownerId of ownerRecipientIds(row)) { + if (excluded.has(ownerId)) continue; + notifications.push(commentNotification(ownerId, row, config, config.ownerKind, commentId, notificationPreferenceTypes.comments)); + excluded.add(ownerId); + } + for (const recipientId of commenterIdsBefore(config, row.contextId, commentId)) { + if (excluded.has(recipientId)) continue; + notifications.push(commentNotification(recipientId, row, config, config.followedKind, commentId, notificationPreferenceTypes.comments)); + excluded.add(recipientId); + } + return createNotifications(notifications); +} + +function ownerRecipientIds(row: CommentEventRow) { + return [row.ownerId, row.wallOwnerId].filter((id): id is number => typeof id === "number"); +} + +function commentNotification( + recipientId: number, + row: CommentEventRow, + config: CommentConfig, + kind: NotificationKind, + commentId: number, + preferenceType?: NotificationPreferenceType +): NotificationTargetInput { + return { + recipientId, + actorId: row.actorId, + kind, + preferenceType, + subjectType: config.subjectType, + subjectId: commentId, + contextType: config.contextType, + contextId: row.contextId + }; +} + +function commentEventRow(config: CommentConfig, commentId: number) { + return sqlite + .prepare( + `SELECT c.author_id AS actorId, c.${config.column} AS contextId, parent.author_id AS parentAuthorId, + ${config.ownerSelect} + FROM ${config.table} c + ${config.ownerJoin} + LEFT JOIN ${config.table} parent ON parent.id = c.parent_id + WHERE c.id = ?` + ) + .get(commentId) as CommentEventRow | undefined; +} + +function commenterIdsBefore(config: CommentConfig, contextId: number, beforeCommentId: number) { + return ( + sqlite + .prepare( + `SELECT DISTINCT author_id AS recipientId + FROM ${config.table} + WHERE ${config.column} = ? AND id < ?` + ) + .all(contextId, beforeCommentId) as Array<{ recipientId: number }> + ).map((row) => row.recipientId); +} diff --git a/src/server/db/notifications/index.ts b/src/server/db/notifications/index.ts new file mode 100644 index 0000000..dbfa3c8 --- /dev/null +++ b/src/server/db/notifications/index.ts @@ -0,0 +1,4 @@ +export * from "./comments.js"; +export * from "./mutations.js"; +export * from "./preferences.js"; +export * from "./queries.js"; diff --git a/src/server/db/notifications/mutations.ts b/src/server/db/notifications/mutations.ts new file mode 100644 index 0000000..25b2876 --- /dev/null +++ b/src/server/db/notifications/mutations.ts @@ -0,0 +1,78 @@ +import { + notificationKinds, + notificationPreferenceTypes, + type NotificationKind, + type NotificationPreferenceType, + type NotificationSubjectType +} from "../../../notifications.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { sqlite } from "../client.js"; +import { notificationFrom, visibleNotificationParams, visibleNotificationSql } from "./sql.js"; +import { createNotification } from "./write.js"; + +type PropOwnerRow = { + ownerId: number | null; +}; + +export function notifyPostProp(postId: number, actorId: number) { + return notifyOwnerProp("posts", postId, actorId, notificationKinds.postProp, "post", notificationPreferenceTypes.props); +} + +export function notifyBlogProp(blogId: number, actorId: number) { + return notifyOwnerProp("blogs", blogId, actorId, notificationKinds.blogProp, "blog", notificationPreferenceTypes.props); +} + +export function notifyFavorite(actorId: number, recipientId: number) { + return notifyUserEvent(actorId, recipientId, notificationKinds.favorite, notificationPreferenceTypes.favorites); +} + +export function notifyFriendAccepted(actorId: number, recipientId: number) { + return notifyUserEvent(actorId, recipientId, notificationKinds.friendAccepted, notificationPreferenceTypes.friendAccepts); +} + +export function markVisibleNotificationsRead(viewer: CurrentUser) { + sqlite + .prepare( + `UPDATE notifications + SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) + WHERE id IN ( + SELECT n.id ${notificationFrom} + WHERE ${visibleNotificationSql} AND n.read_at IS NULL + )` + ) + .run(...visibleNotificationParams(viewer.id)); +} + +function notifyOwnerProp( + table: "blogs" | "posts", + subjectId: number, + actorId: number, + kind: NotificationKind, + subjectType: Extract, + preferenceType: NotificationPreferenceType +) { + const row = sqlite.prepare(`SELECT author_id AS ownerId FROM ${table} WHERE id = ?`).get(subjectId) as PropOwnerRow | undefined; + return createNotification({ + recipientId: row?.ownerId, + actorId, + kind, + preferenceType, + subjectType, + subjectId, + contextType: subjectType, + contextId: subjectId + }); +} + +function notifyUserEvent(actorId: number, recipientId: number, kind: NotificationKind, preferenceType: NotificationPreferenceType) { + return createNotification({ + recipientId, + actorId, + kind, + preferenceType, + subjectType: "user", + subjectId: actorId, + contextType: "user", + contextId: actorId + }); +} diff --git a/src/server/db/notifications/preferences.ts b/src/server/db/notifications/preferences.ts new file mode 100644 index 0000000..4b53f09 --- /dev/null +++ b/src/server/db/notifications/preferences.ts @@ -0,0 +1,42 @@ +import { + notificationPreferenceTypeNames, + type NotificationPreferences, + type NotificationPreferenceType +} from "../../../notifications.js"; +import { sqlite } from "../client.js"; + +export function defaultNotificationPreferences(): NotificationPreferences { + return Object.fromEntries(notificationPreferenceTypeNames.map((type) => [type, true])) as NotificationPreferences; +} + +export function notificationPreferencesForUser(userId: number) { + const preferences = defaultNotificationPreferences(); + const rows = sqlite + .prepare("SELECT type, enabled FROM notification_preferences WHERE user_id = ?") + .all(userId) as Array<{ type: string; enabled: 0 | 1 }>; + for (const row of rows) { + if (isNotificationPreferenceType(row.type)) preferences[row.type] = Boolean(row.enabled); + } + return preferences; +} + +export function updateNotificationPreferences(userId: number, preferences: NotificationPreferences) { + sqlite.transaction(() => { + sqlite.prepare("DELETE FROM notification_preferences WHERE user_id = ?").run(userId); + const insert = sqlite.prepare("INSERT INTO notification_preferences (user_id, type, enabled) VALUES (?, ?, 0)"); + for (const type of notificationPreferenceTypeNames) { + if (!preferences[type]) insert.run(userId, type); + } + })(); +} + +export function notificationPreferenceEnabled(userId: number, type: NotificationPreferenceType) { + const row = sqlite + .prepare("SELECT enabled FROM notification_preferences WHERE user_id = ? AND type = ?") + .get(userId, type) as { enabled: 0 | 1 } | undefined; + return row ? Boolean(row.enabled) : true; +} + +function isNotificationPreferenceType(type: string): type is NotificationPreferenceType { + return notificationPreferenceTypeNames.includes(type as NotificationPreferenceType); +} diff --git a/src/server/db/notifications/queries.ts b/src/server/db/notifications/queries.ts new file mode 100644 index 0000000..b554682 --- /dev/null +++ b/src/server/db/notifications/queries.ts @@ -0,0 +1,38 @@ +import { + decodeKeysetCursor, + keysetBeforeCondition, + normalizePageLimit, + pageFromRows, + type PageOptions +} from "../../pagination.js"; +import { limits } from "../../../policy.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { sqlite } from "../client.js"; +import { notificationFrom, notificationRows, visibleNotificationParams, visibleNotificationSql } from "./sql.js"; + +export function unreadNotificationCount(userId: number) { + return ( + sqlite + .prepare( + `SELECT COUNT(*) AS count ${notificationFrom} + WHERE ${visibleNotificationSql} AND n.read_at IS NULL` + ) + .get(...visibleNotificationParams(userId)) as { count: number } + ).count; +} + +export function notificationsForUser(viewer: CurrentUser, options: PageOptions = {}) { + const limit = normalizePageLimit(options.limit, limits.listPage, limits.exportRows); + const before = keysetBeforeCondition(decodeKeysetCursor(options.before), "n.created_at", "n.id"); + return pageFromRows( + notificationRows( + `WHERE ${visibleNotificationSql} + ${before.sql} + ORDER BY n.created_at DESC, n.id DESC LIMIT ?`, + ...visibleNotificationParams(viewer.id), + ...before.params, + limit + 1 + ), + limit + ); +} diff --git a/src/server/db/notifications/sql.ts b/src/server/db/notifications/sql.ts new file mode 100644 index 0000000..e089a67 --- /dev/null +++ b/src/server/db/notifications/sql.ts @@ -0,0 +1,47 @@ +import type { NotificationItem } from "../../../models.js"; +import { sqlite } from "../client.js"; + +const notificationColumns = `n.id, n.recipient_id AS recipientId, n.actor_id AS actorId, + actor.username AS actorName, actorProfile.handle AS actorHandle, + n.kind, n.subject_type AS subjectType, n.subject_id AS subjectId, + n.context_type AS contextType, n.context_id AS contextId, + CASE n.context_type WHEN 'post' THEN postContext.author_id ELSE NULL END AS contextPostAuthorId, + CASE n.context_type WHEN 'post' THEN postContext.wall_user_id ELSE NULL END AS contextPostWallUserId, + CASE n.context_type + WHEN 'blog' THEN blogContext.title + WHEN 'skin' THEN skinContext.title + ELSE NULL + END AS contextTitle, + n.read_at AS readAt, n.created_at AS createdAt`; + +export const notificationFrom = `FROM notifications n + JOIN users actor ON actor.id = n.actor_id + JOIN profiles actorProfile ON actorProfile.user_id = actor.id + LEFT JOIN posts postContext ON postContext.id = n.context_id AND n.context_type = 'post' + LEFT JOIN blogs blogContext ON blogContext.id = n.context_id AND n.context_type = 'blog' + LEFT JOIN skins skinContext ON skinContext.id = n.context_id AND n.context_type = 'skin'`; + +const visibleActorSql = `(actor.banned_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = ? AND b.blocked_id = actor.id) + OR (b.blocked_id = ? AND b.blocker_id = actor.id) + ) +)`; + +const liveContextSql = `( + (n.context_type = 'blog' AND blogContext.id IS NOT NULL) + OR (n.context_type = 'post' AND postContext.id IS NOT NULL) + OR (n.context_type = 'skin' AND skinContext.id IS NOT NULL) + OR (n.context_type = 'user' AND n.context_id = actor.id) +)`; + +export const visibleNotificationSql = `n.recipient_id = ? AND ${visibleActorSql} AND ${liveContextSql}`; + +export function notificationRows(tail: string, ...params: unknown[]) { + return sqlite.prepare(`SELECT ${notificationColumns} ${notificationFrom} ${tail}`).all(...params) as NotificationItem[]; +} + +export function visibleNotificationParams(userId: number) { + return [userId, userId, userId] as const; +} diff --git a/src/server/db/notifications/write.ts b/src/server/db/notifications/write.ts new file mode 100644 index 0000000..02ea6a4 --- /dev/null +++ b/src/server/db/notifications/write.ts @@ -0,0 +1,47 @@ +import type { + NotificationContextType, + NotificationKind, + NotificationPreferenceType, + NotificationSubjectType +} from "../../../notifications.js"; +import { sqlite } from "../client.js"; +import { isBlockedBetween } from "../relationships.js"; +import { notificationPreferenceEnabled } from "./preferences.js"; + +type NotificationInput = { + actorId: number; + contextId: number; + contextType: NotificationContextType; + kind: NotificationKind; + subjectId: number; + subjectType: NotificationSubjectType; +}; + +export type NotificationTargetInput = NotificationInput & { + preferenceType?: NotificationPreferenceType; + recipientId: number | null | undefined; +}; + +export function createNotification(input: NotificationTargetInput) { + if (!input.recipientId || input.recipientId === input.actorId || isBlockedBetween(input.recipientId, input.actorId)) return false; + if (input.preferenceType && !notificationPreferenceEnabled(input.recipientId, input.preferenceType)) return false; + return sqlite + .prepare( + `INSERT INTO notifications ( + recipient_id, actor_id, kind, subject_type, subject_id, context_type, context_id + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(input.recipientId, input.actorId, input.kind, input.subjectType, input.subjectId, input.contextType, input.contextId) + .changes > 0; +} + +export function createNotifications(inputs: readonly NotificationTargetInput[]) { + if (!inputs.length) return false; + return sqlite.transaction(() => { + let created = false; + for (const input of inputs) { + if (createNotification(input)) created = true; + } + return created; + })(); +} diff --git a/src/server/db/personCards.ts b/src/server/db/personCards.ts new file mode 100644 index 0000000..2e69f89 --- /dev/null +++ b/src/server/db/personCards.ts @@ -0,0 +1,8 @@ +import { sqlite } from "./client.js"; +import type { PersonCard } from "../../models.js"; + +const personCardColumns = "u.id, u.username, p.handle, p.pfp"; + +export function personCardRows(tail: string, ...params: unknown[]) { + return sqlite.prepare(`SELECT ${personCardColumns} ${tail}`).all(...params) as PersonCard[]; +} diff --git a/src/server/db/posts/commentActivity.ts b/src/server/db/posts/commentActivity.ts new file mode 100644 index 0000000..41dfd21 --- /dev/null +++ b/src/server/db/posts/commentActivity.ts @@ -0,0 +1,228 @@ +import type { CurrentUser } from "../../../currentUser.js"; +import type { PostCommentBumpActor, PostItem } from "../../../models.js"; +import { friendshipStatus } from "../../../policy.js"; +import { + decodeKeysetCursor, + keysetBeforeCondition, + normalizePageLimit, + pageFromRows, + type PageOptions +} from "../../pagination.js"; +import { profileVisibilitySql } from "../profileVisibility.js"; +import { postRowsWithOptions } from "./sql.js"; + +type SqlFragment = { + sql: string; + params: unknown[]; +}; + +type ActivityPostRow = PostItem & { + activityAt: string | null; + activitySortId: number; + commentBumpAt: string | null; + commentBumpActorCount: number | null; + commentBumpActor1Id: number | null; + commentBumpActor1Handle: string | null; + commentBumpActor1Name: string | null; + commentBumpActor2Id: number | null; + commentBumpActor2Handle: string | null; + commentBumpActor2Name: string | null; + commentBumpActor3Id: number | null; + commentBumpActor3Handle: string | null; + commentBumpActor3Name: string | null; +}; + +const commentActivitySortOffset = 1_000_000_000_000; + +export const commentActivityTimestampSql = "COALESCE(commentActivity.commentBumpAt, po.created_at)"; + +export function pagedCommentActivityPostRows( + viewer: CurrentUser | null, + options: PageOptions, + fallbackLimit: number, + maxLimit: number, + tailSql: string, + activityTimestampSql: string, + ...params: unknown[] +) { + const limit = normalizePageLimit(options.limit, fallbackLimit, maxLimit); + const activitySortSql = `CASE WHEN commentActivity.commentBumpId IS NOT NULL THEN commentActivity.commentBumpId + ${commentActivitySortOffset} ELSE po.id END`; + const before = keysetBeforeCondition(decodeKeysetCursor(options.before), activityTimestampSql, activitySortSql); + const rows = postRowsWithOptions( + `${tailSql} + ${before.sql} + ORDER BY ${activityTimestampSql} DESC, ${activitySortSql} DESC LIMIT ?`, + viewer, + { + select: `${activityTimestampSql} AS activityAt, + ${activitySortSql} AS activitySortId, + commentActivity.commentBumpAt AS commentBumpAt, + COALESCE(commentActivity.commentBumpActorCount, 0) AS commentBumpActorCount, + commentActivity.commentBumpActor1Id AS commentBumpActor1Id, + commentActivity.commentBumpActor1Handle AS commentBumpActor1Handle, + commentActivity.commentBumpActor1Name AS commentBumpActor1Name, + commentActivity.commentBumpActor2Id AS commentBumpActor2Id, + commentActivity.commentBumpActor2Handle AS commentBumpActor2Handle, + commentActivity.commentBumpActor2Name AS commentBumpActor2Name, + commentActivity.commentBumpActor3Id AS commentBumpActor3Id, + commentActivity.commentBumpActor3Handle AS commentBumpActor3Handle, + commentActivity.commentBumpActor3Name AS commentBumpActor3Name` + }, + ...params, + ...before.params, + limit + 1 + ) as ActivityPostRow[]; + + const page = pageFromRows(rows, limit, (row) => ({ createdAt: row.activityAt ?? row.createdAt, id: row.activitySortId })); + return { items: page.items.map(postFromActivityRow), nextCursor: page.nextCursor }; +} + +function postFromActivityRow(row: ActivityPostRow): PostItem { + const { + activityAt, + activitySortId, + commentBumpAt, + commentBumpActorCount, + commentBumpActor1Id, + commentBumpActor1Handle, + commentBumpActor1Name, + commentBumpActor2Id, + commentBumpActor2Handle, + commentBumpActor2Name, + commentBumpActor3Id, + commentBumpActor3Handle, + commentBumpActor3Name, + ...post + } = row; + const actors = [ + commentBumpActor(commentBumpActor1Id, commentBumpActor1Handle, commentBumpActor1Name), + commentBumpActor(commentBumpActor2Id, commentBumpActor2Handle, commentBumpActor2Name), + commentBumpActor(commentBumpActor3Id, commentBumpActor3Handle, commentBumpActor3Name) + ].filter((actor): actor is PostCommentBumpActor => actor !== null); + + return { + ...post, + commentBump: commentBumpAt && actors.length ? { + commentedAt: commentBumpAt, + commenterCount: Math.max(commentBumpActorCount ?? 0, actors.length), + actors + } : null + }; +} + +function commentBumpActor(id: number | null, handle: string | null, name: string | null): PostCommentBumpActor | null { + return typeof id === "number" && handle && name ? { id, handle, name } : null; +} + +export function wallCommentSourceSql(wallUserId: number): SqlFragment { + return { sql: "activityPost.wall_user_id = ?", params: [wallUserId] }; +} + +export function profileCommentSourceSql(profileId: number): SqlFragment { + return { + sql: "(activityPost.wall_user_id = ? OR (activityPost.group_id IS NOT NULL AND activityPost.author_id = ?))", + params: [profileId, profileId] + }; +} + +export function groupCommentSourceSql(groupId: number): SqlFragment { + return { sql: "activityPost.group_id = ?", params: [groupId] }; +} + +export function feedCommentSourceSql(userId: number): SqlFragment { + return { + sql: `activityComment.author_id = ? + OR EXISTS ( + SELECT 1 FROM friendships activityFriendship + WHERE activityFriendship.status = ? + AND ( + (activityFriendship.sender_id = ? AND activityFriendship.receiver_id = activityComment.author_id) + OR (activityFriendship.receiver_id = ? AND activityFriendship.sender_id = activityComment.author_id) + ) + ) + OR ( + activityPost.group_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM group_members activityViewerGroupMember + WHERE activityViewerGroupMember.group_id = activityPost.group_id AND activityViewerGroupMember.user_id = ? + ) + AND EXISTS ( + SELECT 1 FROM group_members activityCommentGroupMember + WHERE activityCommentGroupMember.group_id = activityPost.group_id AND activityCommentGroupMember.user_id = activityComment.author_id + ) + )`, + params: [userId, friendshipStatus.accepted, userId, userId, userId] + }; +} + +export function postCommentActivityJoin(viewer: CurrentUser | null, source: SqlFragment): SqlFragment { + const visible = profileVisibilitySql(viewer, { user: "activityAuthor", profile: "activityProfile" }); + const parentVisible = profileVisibilitySql(viewer, { user: "activityParentAuthor", profile: "activityParentProfile" }); + return { + sql: `LEFT JOIN ( + SELECT + activityAuthors.postId, + MAX(CASE WHEN activityAuthors.authorRank = 1 THEN activityAuthors.createdAt END) AS commentBumpAt, + MAX(CASE WHEN activityAuthors.authorRank = 1 THEN activityAuthors.commentId END) AS commentBumpId, + COUNT(*) AS commentBumpActorCount, + MAX(CASE WHEN activityAuthors.authorRank = 1 THEN activityAuthors.authorId END) AS commentBumpActor1Id, + MAX(CASE WHEN activityAuthors.authorRank = 1 THEN activityAuthors.authorHandle END) AS commentBumpActor1Handle, + MAX(CASE WHEN activityAuthors.authorRank = 1 THEN activityAuthors.username END) AS commentBumpActor1Name, + MAX(CASE WHEN activityAuthors.authorRank = 2 THEN activityAuthors.authorId END) AS commentBumpActor2Id, + MAX(CASE WHEN activityAuthors.authorRank = 2 THEN activityAuthors.authorHandle END) AS commentBumpActor2Handle, + MAX(CASE WHEN activityAuthors.authorRank = 2 THEN activityAuthors.username END) AS commentBumpActor2Name, + MAX(CASE WHEN activityAuthors.authorRank = 3 THEN activityAuthors.authorId END) AS commentBumpActor3Id, + MAX(CASE WHEN activityAuthors.authorRank = 3 THEN activityAuthors.authorHandle END) AS commentBumpActor3Handle, + MAX(CASE WHEN activityAuthors.authorRank = 3 THEN activityAuthors.username END) AS commentBumpActor3Name + FROM ( + SELECT + activityLatestAuthorComment.*, + ROW_NUMBER() OVER ( + PARTITION BY activityLatestAuthorComment.postId + ORDER BY activityLatestAuthorComment.createdAt DESC, activityLatestAuthorComment.commentId DESC + ) AS authorRank + FROM ( + SELECT + activityVisibleComment.postId, + activityVisibleComment.commentId, + activityVisibleComment.createdAt, + activityVisibleComment.authorId, + activityVisibleComment.username, + activityVisibleComment.authorHandle + FROM ( + SELECT + activityComment.post_id AS postId, + activityComment.id AS commentId, + activityComment.created_at AS createdAt, + activityComment.author_id AS authorId, + activityAuthor.username, + activityProfile.handle AS authorHandle, + ROW_NUMBER() OVER ( + PARTITION BY activityComment.post_id, activityComment.author_id + ORDER BY activityComment.created_at DESC, activityComment.id DESC + ) AS authorCommentRank + FROM post_comments activityComment + JOIN posts activityPost ON activityPost.id = activityComment.post_id + JOIN users activityAuthor ON activityAuthor.id = activityComment.author_id + JOIN profiles activityProfile ON activityProfile.user_id = activityAuthor.id + WHERE ${visible.sql} + AND ( + activityComment.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM post_comments activityParentComment + JOIN users activityParentAuthor ON activityParentAuthor.id = activityParentComment.author_id + JOIN profiles activityParentProfile ON activityParentProfile.user_id = activityParentAuthor.id + WHERE activityParentComment.id = activityComment.parent_id + AND activityParentComment.post_id = activityComment.post_id + AND ${parentVisible.sql} + ) + ) + AND (${source.sql}) + ) activityVisibleComment + WHERE activityVisibleComment.authorCommentRank = 1 + ) activityLatestAuthorComment + ) activityAuthors + GROUP BY activityAuthors.postId + ) commentActivity ON commentActivity.postId = po.id`, + params: [...visible.params, ...parentVisible.params, ...source.params] + }; +} diff --git a/src/server/db/posts/comments.ts b/src/server/db/posts/comments.ts new file mode 100644 index 0000000..7d0e537 --- /dev/null +++ b/src/server/db/posts/comments.ts @@ -0,0 +1,25 @@ +import { addComment, commentRowsFrom, commentsFor, deleteComment } from "../comments.js"; +import { limits } from "../../../policy.js"; +import type { CurrentUser } from "../../../currentUser.js"; + +export function postCommentsFor(postId: number, viewer: CurrentUser | null = null, limit?: number) { + return commentsFor("post", postId, { viewer, limit, order: "oldest" }); +} + +export function addPostComment(postId: number, authorId: number, textHtml: string, parentId?: number, viewer: CurrentUser | null = null): number | null { + return addComment("post", postId, authorId, textHtml, parentId, viewer); +} + +export function deletePostComment(commentId: number, actorId: number, isAdmin = false) { + return deleteComment("post", commentId, actorId, isAdmin); +} + +export function postCommentsByUser(userId: number, limit = limits.exportRows) { + return commentRowsFrom( + "post_comments", + `WHERE c.author_id = ? + ORDER BY c.created_at DESC LIMIT ?`, + userId, + limit + ); +} diff --git a/src/server/db/posts/index.test.ts b/src/server/db/posts/index.test.ts new file mode 100644 index 0000000..50e4ac5 --- /dev/null +++ b/src/server/db/posts/index.test.ts @@ -0,0 +1,153 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CurrentUser } from "../../../currentUser.js"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-posts-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + process.env.BLIISH_ADMIN_USER_ID = "999999"; + + const client = await import("../client.js"); + db = client.sqlite; + const schema = await import("../schema.js"); + schema.initializeDatabase(); + + return { + groups: await import("../groups.js"), + posts: await import("./index.js"), + relationships: await import("../relationships.js"), + users: await import("../users.js") + }; +} + +function currentUser(id: number): CurrentUser { + return { + id, + username: `user-${id}`, + email: `user-${id}@example.test`, + role: "user", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; + delete process.env.BLIISH_ADMIN_USER_ID; +}); + +describe("posts", () => { + it("requires accepted friendship for cross-wall posting", async () => { + const { posts, relationships, users } = await loadIsolatedDb(); + const ownerId = users.createUser({ username: "Owner", email: "owner@example.test", handle: "owner", passwordHash: "hash" }); + const friendId = users.createUser({ username: "Friend", email: "friend@example.test", handle: "friend", passwordHash: "hash" }); + const strangerId = users.createUser({ username: "Stranger", email: "stranger@example.test", handle: "stranger", passwordHash: "hash" }); + + relationships.requestFriend(ownerId, friendId); + relationships.acceptFriend(ownerId, friendId); + + expect(posts.canPostToWall(ownerId, ownerId)).toBe(true); + expect(posts.canPostToWall(friendId, ownerId)).toBe(true); + expect(posts.canPostToWall(strangerId, ownerId)).toBe(false); + }); + + it("lists propped posts the viewer can still interact with", async () => { + const { groups, posts, relationships, users } = await loadIsolatedDb(); + const viewerId = users.createUser({ username: "Viewer", email: "viewer@example.test", handle: "viewer", passwordHash: "hash" }); + const friendId = users.createUser({ username: "Friend", email: "friend@example.test", handle: "friend", passwordHash: "hash" }); + const blockedId = users.createUser({ username: "Blocked", email: "blocked@example.test", handle: "blocked", passwordHash: "hash" }); + relationships.requestFriend(viewerId, friendId); + relationships.acceptFriend(viewerId, friendId); + groups.createGroup(friendId, "Everyone", "description"); + const groupId = groups.createGroup(friendId, "Design Club", "description"); + groups.joinGroup(groupId, viewerId); + + const ownPostId = posts.createWallPost(viewerId, viewerId, "own prop"); + const friendPostId = posts.createWallPost(friendId, friendId, "friend prop"); + const groupPostId = posts.createGroupPost(friendId, groupId, "group prop"); + const blockedPostId = posts.createWallPost(blockedId, blockedId, "blocked prop"); + for (const postId of [ownPostId, friendPostId, groupPostId, blockedPostId]) { + posts.addPostProp(postId, viewerId); + } + relationships.blockUser(blockedId, viewerId); + + expect(new Set(posts.proppedPostsForViewer(currentUser(viewerId), 10).map((post) => post.id))).toEqual( + new Set([ownPostId, friendPostId, groupPostId]) + ); + + groups.leaveGroup(groupId, viewerId); + + expect(new Set(posts.proppedPostsForViewer(currentUser(viewerId), 10).map((post) => post.id))).toEqual( + new Set([ownPostId, friendPostId]) + ); + }); + + it("hides posts from banned authors in viewer-facing post lists", async () => { + const { groups, posts, relationships, users } = await loadIsolatedDb(); + const viewerId = users.createUser({ username: "Viewer", email: "viewer@example.test", handle: "viewer", passwordHash: "hash" }); + const bannedId = users.createUser({ username: "Banned", email: "banned@example.test", handle: "banned", passwordHash: "hash" }); + relationships.requestFriend(viewerId, bannedId); + relationships.acceptFriend(viewerId, bannedId); + const groupId = groups.createGroup(viewerId, "Design Club", "description"); + groups.joinGroup(groupId, bannedId); + + const wallPostId = posts.createWallPost(bannedId, bannedId, "banned wall"); + const groupPostId = posts.createGroupPost(bannedId, groupId, "banned group"); + posts.addPostProp(wallPostId, viewerId); + posts.addPostProp(groupPostId, viewerId); + + users.setUserBanned(bannedId, true); + + expect(posts.getVisiblePost(wallPostId, currentUser(viewerId))).toBeUndefined(); + expect(posts.getVisiblePost(groupPostId, currentUser(viewerId))).toBeUndefined(); + expect(posts.feedPageForUser(currentUser(viewerId), { limit: 10 }).items).toEqual([]); + expect(posts.postsForGroupPage(groupId, currentUser(viewerId), { limit: 10 }).items).toEqual([]); + expect(posts.proppedPostsForViewer(currentUser(viewerId), 10)).toEqual([]); + }); + + it("requires group membership for group posting and interaction", async () => { + const { groups, posts, users } = await loadIsolatedDb(); + const ownerId = users.createUser({ username: "Owner", email: "owner@example.test", handle: "owner", passwordHash: "hash" }); + const memberId = users.createUser({ username: "Member", email: "member@example.test", handle: "member", passwordHash: "hash" }); + const outsiderId = users.createUser({ username: "Outsider", email: "outsider@example.test", handle: "outsider", passwordHash: "hash" }); + groups.createGroup(ownerId, "Everyone", "description"); + const groupId = groups.createGroup(ownerId, "Design Club", "description"); + groups.joinGroup(groupId, memberId); + const postId = posts.createGroupPost(ownerId, groupId, "group post"); + const post = posts.getPost(postId, currentUser(memberId)); + + expect(posts.canPostToGroup(memberId, groupId)).toBe(true); + expect(posts.canPostToGroup(outsiderId, groupId)).toBe(false); + expect(post && posts.canInteractWithPost(post, memberId)).toBe(true); + expect(post && posts.canInteractWithPost(post, outsiderId)).toBe(false); + }); + + it("lists post comments oldest to newest", async () => { + const { posts, users } = await loadIsolatedDb(); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const postId = posts.createWallPost(authorId, authorId, "post"); + const firstCommentId = posts.addPostComment(postId, authorId, "first"); + const secondCommentId = posts.addPostComment(postId, authorId, "second"); + const replyId = posts.addPostComment(postId, authorId, "reply", firstCommentId ?? undefined); + + expect(posts.postCommentsFor(postId, currentUser(authorId)).map((comment) => comment.id)).toEqual([ + firstCommentId, + replyId, + secondCommentId + ]); + }); +}); diff --git a/src/server/db/posts/index.ts b/src/server/db/posts/index.ts new file mode 100644 index 0000000..e9b35d5 --- /dev/null +++ b/src/server/db/posts/index.ts @@ -0,0 +1,4 @@ +export * from "./comments.js"; +export * from "./media.js"; +export * from "./mutations.js"; +export * from "./queries.js"; diff --git a/src/server/db/posts/media.ts b/src/server/db/posts/media.ts new file mode 100644 index 0000000..ba62968 --- /dev/null +++ b/src/server/db/posts/media.ts @@ -0,0 +1,28 @@ +import { sqlite } from "../client.js"; + +export function postImageFilenamesForGroup(groupId: number) { + return postImageRows("group_id = ?", groupId); +} + +export function postImageFilenamesForAccount(userId: number) { + return postImageRows( + "author_id = ? OR wall_user_id = ? OR group_id IN (SELECT id FROM groups WHERE owner_id = ?)", + userId, + userId, + userId + ); +} + +export function postIdsForImage(filename: string) { + const rows = sqlite + .prepare("SELECT id FROM posts WHERE media_filename = ? ORDER BY id DESC") + .all(filename) as { id: number }[]; + return rows.map((row) => row.id); +} + +function postImageRows(whereSql: string, ...params: unknown[]) { + const rows = sqlite + .prepare(`SELECT DISTINCT media_filename AS filename FROM posts WHERE (${whereSql}) AND media_filename IS NOT NULL`) + .all(...params) as { filename: string }[]; + return rows.map((row) => row.filename); +} diff --git a/src/server/db/posts/mutations.ts b/src/server/db/posts/mutations.ts new file mode 100644 index 0000000..74441bb --- /dev/null +++ b/src/server/db/posts/mutations.ts @@ -0,0 +1,65 @@ +import { friendshipStatus } from "../../../policy.js"; +import { sqlite } from "../client.js"; +import { isGroupMember } from "../groups.js"; +import type { PostItem } from "../../../models.js"; + +export function createWallPost(authorId: number, wallUserId: number, bodyHtml: string, mediaFilename?: string | null) { + const info = sqlite + .prepare("INSERT INTO posts (author_id, wall_user_id, body_html, media_filename) VALUES (?, ?, ?, ?)") + .run(authorId, wallUserId, bodyHtml, mediaFilename ?? null); + return Number(info.lastInsertRowid); +} + +export function createGroupPost(authorId: number, groupId: number, bodyHtml: string, mediaFilename?: string | null) { + const info = sqlite + .prepare("INSERT INTO posts (author_id, group_id, body_html, media_filename) VALUES (?, ?, ?, ?)") + .run(authorId, groupId, bodyHtml, mediaFilename ?? null); + return Number(info.lastInsertRowid); +} + +export function canPostToWall(authorId: number, wallUserId: number) { + if (authorId === wallUserId) return true; + return Boolean( + sqlite + .prepare( + `SELECT 1 FROM friendships f + WHERE f.status = ? + AND ((f.sender_id = ? AND f.receiver_id = ?) OR (f.sender_id = ? AND f.receiver_id = ?)) + AND NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = ? AND b.blocked_id = ?) OR (b.blocker_id = ? AND b.blocked_id = ?) + ) + LIMIT 1` + ) + .get(friendshipStatus.accepted, authorId, wallUserId, wallUserId, authorId, authorId, wallUserId, wallUserId, authorId) + ); +} + +export function canPostToGroup(authorId: number, groupId: number) { + return isGroupMember(groupId, authorId); +} + +export function canInteractWithPost(post: PostItem, userId: number) { + return post.groupId ? isGroupMember(post.groupId, userId) : true; +} + +export function deletePost(postId: number, actorId: number, isAdmin = false) { + const row = sqlite + .prepare( + `SELECT po.author_id AS authorId, po.wall_user_id AS wallUserId, + g.owner_id AS groupOwnerId, po.media_filename AS mediaFilename + FROM posts po LEFT JOIN groups g ON g.id = po.group_id + WHERE po.id = ?` + ) + .get(postId) as { authorId: number; wallUserId: number | null; groupOwnerId: number | null; mediaFilename: string | null } | undefined; + if (!row || (!isAdmin && actorId !== row.authorId && actorId !== row.wallUserId && actorId !== row.groupOwnerId)) return false; + return sqlite.prepare("DELETE FROM posts WHERE id = ?").run(postId).changes > 0 ? row.mediaFilename ?? null : false; +} + +export function addPostProp(postId: number, userId: number) { + return sqlite.prepare("INSERT OR IGNORE INTO post_props (post_id, user_id) VALUES (?, ?)").run(postId, userId).changes > 0; +} + +export function removePostProp(postId: number, userId: number) { + sqlite.prepare("DELETE FROM post_props WHERE post_id = ? AND user_id = ?").run(postId, userId); +} diff --git a/src/server/db/posts/queries.ts b/src/server/db/posts/queries.ts new file mode 100644 index 0000000..f90a6d5 --- /dev/null +++ b/src/server/db/posts/queries.ts @@ -0,0 +1,237 @@ +import type { PageOptions } from "../../pagination.js"; +import { friendshipStatus, limits } from "../../../policy.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { authorVisibleSql, groupOwnerVisibleSql, postRows, viewerId } from "./sql.js"; +import { profileVisibilitySql } from "../profileVisibility.js"; +import { groupPostAccessSql } from "../groups.js"; +import { containsLikePattern, likeEscapeClause } from "../like.js"; +import { + commentActivityTimestampSql, + feedCommentSourceSql, + groupCommentSourceSql, + pagedCommentActivityPostRows, + postCommentActivityJoin, + profileCommentSourceSql +} from "./commentActivity.js"; + +export function getPost(id: number, viewer: CurrentUser | null = null) { + return postRows("WHERE po.id = ? LIMIT 1", viewer, id)[0]; +} + +export function getVisiblePost(id: number, viewer: CurrentUser | null) { + const visiblePost = visiblePostAccessSql(viewer); + const author = authorVisibleSql(viewer); + return postRows( + `WHERE po.id = ? AND ${visiblePost.sql} AND ${author.sql} LIMIT 1`, + viewer, + id, + ...visiblePost.params, + ...author.params + )[0]; +} + +export function postsForProfilePage(profileId: number, viewer: CurrentUser | null, options: PageOptions = {}) { + const visiblePost = visiblePostAccessSql(viewer); + const author = authorVisibleSql(viewer); + const commentActivity = postCommentActivityJoin(viewer, profileCommentSourceSql(profileId)); + return pagedCommentActivityPostRows( + viewer, + options, + limits.listPage, + limits.listPage, + `${commentActivity.sql} + WHERE ${visiblePost.sql} + AND ${author.sql} + AND (po.wall_user_id = ? OR (po.group_id IS NOT NULL AND po.author_id = ?))`, + commentActivityTimestampSql, + ...commentActivity.params, + ...visiblePost.params, + ...author.params, + profileId, + profileId + ); +} + +export function postsForGroupPage(groupId: number, viewer: CurrentUser | null, options: PageOptions = {}) { + const author = authorVisibleSql(viewer); + const groupOwner = groupOwnerVisibleSql(viewer); + const groupAccess = groupPostAccessSql(viewer); + const commentActivity = postCommentActivityJoin(viewer, groupCommentSourceSql(groupId)); + return pagedCommentActivityPostRows( + viewer, + options, + limits.listPage, + limits.listPage, + `${commentActivity.sql} + WHERE po.group_id = ? AND ${groupAccess.sql} AND ${groupOwner.sql} AND ${author.sql}`, + commentActivityTimestampSql, + ...commentActivity.params, + groupId, + ...groupAccess.params, + ...groupOwner.params, + ...author.params + ); +} + +export function searchPosts(query: string, viewer: CurrentUser | null, limit = limits.listPage) { + if (!viewer) return []; + const pattern = containsLikePattern(query); + const visiblePost = visiblePostAccessSql(viewer); + const author = authorVisibleSql(viewer); + return postRows( + `WHERE po.body_html LIKE ? ${likeEscapeClause} AND ${visiblePost.sql} AND ${author.sql} + ORDER BY po.created_at DESC, po.id DESC LIMIT ?`, + viewer, + pattern, + ...visiblePost.params, + ...author.params, + limit + ); +} + +export function feedPageForUser(viewer: CurrentUser, options: PageOptions = {}) { + const userId = viewer.id; + const visiblePost = visiblePostAccessSql(viewer); + const author = authorVisibleSql(viewer); + const commentActivity = postCommentActivityJoin(viewer, feedCommentSourceSql(userId)); + const feedSource = feedPostSourceSql(userId); + return pagedCommentActivityPostRows( + viewer, + options, + limits.feedPosts, + limits.listPage, + `${commentActivity.sql} + WHERE ${visiblePost.sql} + AND ${author.sql} + AND ((${feedSource.sql}) OR commentActivity.postId IS NOT NULL)`, + commentActivityTimestampSql, + ...commentActivity.params, + ...visiblePost.params, + ...author.params, + ...feedSource.params + ); +} + +function visiblePostAccessSql(viewer: CurrentUser | null) { + const wallOwner = profileVisibilitySql(viewer); + const groupAccess = groupPostAccessSql(viewer); + const groupOwner = groupOwnerVisibleSql(viewer); + const knownAuthor = knownAuthorPostSql(viewer); + return { + sql: `( + (po.wall_user_id IS NOT NULL AND ${wallOwner.sql}) + OR ( + po.group_id IS NOT NULL + AND (${groupAccess.sql} OR ${knownAuthor.sql}) + AND ${groupOwner.sql} + ) + )`, + params: [...wallOwner.params, ...groupAccess.params, ...knownAuthor.params, ...groupOwner.params] + }; +} + +function feedPostSourceSql(userId: number) { + const knownAuthor = knownAuthorPostSql(userId); + return { + sql: `( + po.wall_user_id IS NOT NULL + AND ( + po.wall_user_id = ? + OR EXISTS ( + SELECT 1 FROM friendships feedWallFriendship + WHERE feedWallFriendship.status = ? + AND ( + (feedWallFriendship.sender_id = ? AND feedWallFriendship.receiver_id = po.wall_user_id) + OR (feedWallFriendship.receiver_id = ? AND feedWallFriendship.sender_id = po.wall_user_id) + ) + ) + OR ${knownAuthor.sql} + ) + ) OR ( + po.group_id IS NOT NULL + AND ( + EXISTS ( + SELECT 1 FROM group_members feedGroupMember + WHERE feedGroupMember.group_id = po.group_id AND feedGroupMember.user_id = ? + ) + OR ${knownAuthor.sql} + ) + )`, + params: [userId, friendshipStatus.accepted, userId, userId, ...knownAuthor.params, userId, ...knownAuthor.params] + }; +} + +function knownAuthorPostSql(viewerOrId: CurrentUser | number | null) { + const userId = typeof viewerOrId === "number" ? viewerOrId : viewerOrId?.id; + if (!userId) return { sql: "0 = 1", params: [] }; + return { + sql: `(po.author_id = ? OR EXISTS ( + SELECT 1 FROM friendships knownAuthorFriendship + WHERE knownAuthorFriendship.status = ? + AND ( + (knownAuthorFriendship.sender_id = ? AND knownAuthorFriendship.receiver_id = po.author_id) + OR (knownAuthorFriendship.receiver_id = ? AND knownAuthorFriendship.sender_id = po.author_id) + ) + ))`, + params: [userId, friendshipStatus.accepted, userId, userId] + }; +} + +export function postsAuthoredByUser(userId: number, limit = limits.exportRows) { + const viewer = exportViewer(userId); + return postRows("WHERE po.author_id = ? ORDER BY po.created_at DESC, po.id DESC LIMIT ?", viewer, userId, limit); +} + +export function postsOnWallForUser(userId: number, limit = limits.exportRows) { + const viewer = exportViewer(userId); + return postRows("WHERE po.wall_user_id = ? ORDER BY po.created_at DESC, po.id DESC LIMIT ?", viewer, userId, limit); +} + +export function proppedPostsForUser(userId: number, limit = limits.exportRows) { + const viewer = exportViewer(userId); + return postRows( + `WHERE po.id IN (SELECT p.post_id FROM post_props p WHERE p.user_id = ?) + ORDER BY po.created_at DESC, po.id DESC LIMIT ?`, + viewer, + userId, + limit + ); +} + +export function proppedPostsForViewer(viewer: CurrentUser, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + const author = authorVisibleSql(viewer); + const groupOwner = groupOwnerVisibleSql(viewer); + const groupAccess = groupPostAccessSql(viewer); + return postRows( + `JOIN post_props viewer_prop ON viewer_prop.post_id = po.id AND viewer_prop.user_id = ? + WHERE ( + (po.wall_user_id IS NOT NULL AND ${visible.sql}) + OR ( + po.group_id IS NOT NULL + AND ${groupAccess.sql} + AND ${groupOwner.sql} + ) + ) AND ${author.sql} + ORDER BY viewer_prop.created_at DESC, po.id DESC LIMIT ?`, + viewer, + viewer.id, + ...visible.params, + ...groupAccess.params, + ...groupOwner.params, + ...author.params, + limit + ); +} + +function exportViewer(userId: number): CurrentUser { + return { + id: userId, + username: "", + email: "", + role: "user", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + }; +} diff --git a/src/server/db/posts/sql.ts b/src/server/db/posts/sql.ts new file mode 100644 index 0000000..1cb4955 --- /dev/null +++ b/src/server/db/posts/sql.ts @@ -0,0 +1,83 @@ +import type { CurrentUser } from "../../../currentUser.js"; +import { sqlite } from "../client.js"; +import { profileVisibilitySql } from "../profileVisibility.js"; +import type { PostItem } from "../../../models.js"; + +type PostRowsOptions = { + select?: string; +}; + +const postColumns = (propVisibilitySql: string, commentVisibilitySql: string, parentCommentVisibilitySql: string) => `po.id, po.body_html AS bodyHtml, po.media_filename AS mediaFilename, + po.created_at AS createdAt, po.updated_at AS updatedAt, + author.id AS authorId, author.role AS authorRole, authorProfile.handle AS authorHandle, authorProfile.skin_html AS authorSkinHtml, author.username, authorProfile.pfp, + u.id AS wallUserId, p.handle AS wallUserHandle, u.username AS wallUsername, + g.id AS groupId, g.name AS groupName, g.owner_id AS groupOwnerId, + ( + SELECT COUNT(*) + FROM post_props props + JOIN users prop_user ON prop_user.id = props.user_id + JOIN profiles prop_profile ON prop_profile.user_id = prop_user.id + WHERE props.post_id = po.id AND ${propVisibilitySql} + ) AS propCount, + EXISTS(SELECT 1 FROM post_props own_prop WHERE own_prop.post_id = po.id AND own_prop.user_id = ?) AS proppedByViewer, + CASE + WHEN po.group_id IS NULL THEN 1 + WHEN EXISTS ( + SELECT 1 FROM group_members interact_member + WHERE interact_member.group_id = po.group_id AND interact_member.user_id = ? + ) THEN 1 + ELSE 0 + END AS viewerCanInteract, + ( + SELECT COUNT(*) + FROM post_comments comments + JOIN users comment_author ON comment_author.id = comments.author_id + JOIN profiles comment_profile ON comment_profile.user_id = comment_author.id + WHERE comments.post_id = po.id + AND ${commentVisibilitySql} + AND ( + comments.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM post_comments parent_comment + JOIN users parent_author ON parent_author.id = parent_comment.author_id + JOIN profiles parent_profile ON parent_profile.user_id = parent_author.id + WHERE parent_comment.id = comments.parent_id + AND parent_comment.post_id = comments.post_id + AND ${parentCommentVisibilitySql} + ) + ) + ) AS commentCount`; + +const postFrom = `FROM posts po + JOIN users author ON author.id = po.author_id + JOIN profiles authorProfile ON authorProfile.user_id = author.id + LEFT JOIN users u ON u.id = po.wall_user_id + LEFT JOIN profiles p ON p.user_id = u.id + LEFT JOIN groups g ON g.id = po.group_id + LEFT JOIN users groupOwner ON groupOwner.id = g.owner_id + LEFT JOIN profiles groupOwnerProfile ON groupOwnerProfile.user_id = groupOwner.id`; + +export function postRows(tail: string, viewer: CurrentUser | null, ...params: unknown[]) { + return postRowsWithOptions(tail, viewer, {}, ...params); +} + +export function postRowsWithOptions(tail: string, viewer: CurrentUser | null, options: PostRowsOptions = {}, ...params: unknown[]) { + const visibleProps = profileVisibilitySql(viewer, { user: "prop_user", profile: "prop_profile" }); + const visibleComments = profileVisibilitySql(viewer, { user: "comment_author", profile: "comment_profile" }); + const visibleParentComments = profileVisibilitySql(viewer, { user: "parent_author", profile: "parent_profile" }); + const extraColumns = options.select ? `, ${options.select}` : ""; + return sqlite + .prepare(`SELECT ${postColumns(visibleProps.sql, visibleComments.sql, visibleParentComments.sql)}${extraColumns} ${postFrom} ${tail}`) + .all(...visibleProps.params, viewerId(viewer), viewerId(viewer), ...visibleComments.params, ...visibleParentComments.params, ...params) as PostItem[]; +} + +export function viewerId(viewer: CurrentUser | null) { + return viewer?.id ?? 0; +} + +export function authorVisibleSql(viewer: CurrentUser | null): { sql: string; params: unknown[] } { + return profileVisibilitySql(viewer, { user: "author", profile: "authorProfile" }); +} + +export function groupOwnerVisibleSql(viewer: CurrentUser | null): { sql: string; params: unknown[] } { + return profileVisibilitySql(viewer, { user: "groupOwner", profile: "groupOwnerProfile" }); +} diff --git a/src/server/db/profileVisibility.ts b/src/server/db/profileVisibility.ts new file mode 100644 index 0000000..ba91c96 --- /dev/null +++ b/src/server/db/profileVisibility.ts @@ -0,0 +1,30 @@ +import { friendshipStatus } from "../../policy.js"; +import { canBypassVisibility } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; + +type ProfileVisibilityAliases = { + profile?: string; + user?: string; +}; + +export function profileVisibilitySql(viewer: CurrentUser | null, aliases: ProfileVisibilityAliases = {}) { + const viewerId = viewer?.id ?? 0; + const bypass = canBypassVisibility(viewer) ? 1 : 0; + const user = aliases.user ?? "u"; + const profile = aliases.profile ?? "p"; + return { + sql: `${user}.banned_at IS NULL AND ( + ${profile}.private = 0 OR ${user}.id = ? OR ? = 1 OR EXISTS ( + SELECT 1 FROM friendships f + WHERE f.status = ? + AND ((f.sender_id = ? AND f.receiver_id = ${user}.id) OR (f.receiver_id = ? AND f.sender_id = ${user}.id)) + ) + ) AND ( + ${user}.id = ? OR ? = 1 OR NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = ? AND b.blocked_id = ${user}.id) OR (b.blocked_id = ? AND b.blocker_id = ${user}.id) + ) + )`, + params: [viewerId, bypass, friendshipStatus.accepted, viewerId, viewerId, viewerId, bypass, viewerId, viewerId] + }; +} diff --git a/src/server/db/rateLimits.test.ts b/src/server/db/rateLimits.test.ts new file mode 100644 index 0000000..65aea0f --- /dev/null +++ b/src/server/db/rateLimits.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-rate-limits-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("./client.js"); + db = client.sqlite; + const schema = await import("./schema.js"); + schema.initializeDatabase(); + + return { + rateLimits: await import("./rateLimits.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("rate limits", () => { + it("blocks a subject after the configured action limit", async () => { + const { rateLimits } = await loadIsolatedDb(); + const input = { + action: "post.create", + subjectHash: "subject-a", + limit: 2, + pruneAfterSeconds: 60 * 60, + windowSeconds: 60 + }; + + expect(rateLimits.consumeRateLimit(input)).toBe(true); + expect(rateLimits.consumeRateLimit(input)).toBe(true); + expect(rateLimits.consumeRateLimit(input)).toBe(false); + + expect(rateLimits.consumeRateLimit({ ...input, subjectHash: "subject-b" })).toBe(true); + expect(rateLimits.consumeRateLimit({ ...input, action: "comment.create" })).toBe(true); + }); + + it("enables raid mode without blocking login or staff recovery", async () => { + const { rateLimits } = await loadIsolatedDb(); + db?.prepare("INSERT INTO users (username, email, email_canonical, password_hash) VALUES (?, ?, ?, ?)").run("Admin", "admin@example.test", "admin@example.test", "hash"); + + expect(rateLimits.raidModeActive()).toBe(false); + + rateLimits.saveRateLimitSettings([{ action: "post.create", limit: 2, windowSeconds: 30 }], 1); + rateLimits.enableRaidMode(1); + + expect(rateLimits.raidModeActive()).toBe(true); + expect(rateLimits.rateLimitPolicyFor("auth.signup").limit).toBe(0); + expect(rateLimits.rateLimitPolicyFor("post.create").limit).toBe(0); + expect(rateLimits.rateLimitPolicyFor("auth.login").limit).toBeGreaterThan(0); + expect(rateLimits.rateLimitPolicyFor("auth.reset").limit).toBeGreaterThan(0); + expect(rateLimits.rateLimitPolicyFor("staff.write").limit).toBeGreaterThan(0); + + rateLimits.disableRaidMode(); + + expect(rateLimits.raidModeActive()).toBe(false); + expect(rateLimits.rateLimitPolicyFor("auth.signup").limit).toBeGreaterThan(0); + expect(rateLimits.rateLimitPolicyFor("post.create")).toEqual({ limit: 2, windowSeconds: 30 }); + }); +}); diff --git a/src/server/db/rateLimits.ts b/src/server/db/rateLimits.ts new file mode 100644 index 0000000..8e02ad9 --- /dev/null +++ b/src/server/db/rateLimits.ts @@ -0,0 +1,196 @@ +import { sqlite } from "./client.js"; +import { rateLimits, type RateLimitAction } from "../../policy.js"; +import type { RateLimitSetting } from "../../models.js"; +import { recordFromUnknown } from "../../values.js"; +import { deleteSetting, saveSetting, settingRow } from "./settings.js"; + +type ConsumeRateLimitInput = { + action: string; + subjectHash: string; + limit: number; + pruneAfterSeconds: number; + windowSeconds: number; +}; + +type StoredRateLimitSetting = { + action: RateLimitAction; + limit: number; + windowSeconds: number; +}; + +const raidModeSnapshotKey = "rate_limits.raid_mode_snapshot.v1"; + +export function consumeRateLimit(input: ConsumeRateLimitInput) { + const nowSeconds = Math.floor(Date.now() / 1000); + const windowStart = nowSeconds - (nowSeconds % Math.max(1, Math.floor(input.windowSeconds))); + const pruneBefore = nowSeconds - Math.max(1, Math.floor(input.pruneAfterSeconds)); + + return sqlite.transaction(() => { + sqlite.prepare("DELETE FROM rate_limit_counters WHERE window_start < ?").run(pruneBefore); + + const row = sqlite + .prepare("SELECT count FROM rate_limit_counters WHERE action = ? AND subject_hash = ? AND window_start = ?") + .get(input.action, input.subjectHash, windowStart) as { count: number } | undefined; + + if ((row?.count ?? 0) >= input.limit) return false; + + sqlite + .prepare( + `INSERT INTO rate_limit_counters (action, subject_hash, window_start, count) VALUES (?, ?, ?, 1) + ON CONFLICT(action, subject_hash, window_start) + DO UPDATE SET count = count + 1, updated_at = CURRENT_TIMESTAMP` + ) + .run(input.action, input.subjectHash, windowStart); + return true; + })(); +} + +export function rateLimitPolicyFor(action: RateLimitAction) { + const setting = sqlite + .prepare("SELECT limit_count AS limitValue, window_seconds AS windowSeconds FROM rate_limit_settings WHERE action = ?") + .get(action) as { limitValue: number; windowSeconds: number } | undefined; + return setting ? { limit: setting.limitValue, windowSeconds: setting.windowSeconds } : rateLimits.actions[action]; +} + +export function listRateLimitSettings() { + const rows = sqlite + .prepare("SELECT action, limit_count AS limitValue, window_seconds AS windowSeconds, updated_at AS updatedAt FROM rate_limit_settings") + .all() as Array<{ action: string; limitValue: number; windowSeconds: number; updatedAt: string }>; + const overrides = new Map(rows.map((row) => [row.action, row])); + + return rateLimitActionNames.map((action) => { + const defaults = rateLimits.actions[action]; + const override = overrides.get(action); + return { + action, + limit: override?.limitValue ?? defaults.limit, + windowSeconds: override?.windowSeconds ?? defaults.windowSeconds, + defaultLimit: defaults.limit, + defaultWindowSeconds: defaults.windowSeconds, + overridden: Boolean(override), + updatedAt: override?.updatedAt ?? null + } satisfies RateLimitSetting; + }); +} + +export function saveRateLimitSettings(settings: Array>, actorId: number) { + sqlite.transaction(() => { + const statement = sqlite.prepare( + `INSERT INTO rate_limit_settings (action, limit_count, window_seconds, updated_by, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(action) + DO UPDATE SET limit_count = excluded.limit_count, + window_seconds = excluded.window_seconds, + updated_by = excluded.updated_by, + updated_at = CURRENT_TIMESTAMP` + ); + + for (const setting of settings) { + statement.run(setting.action, setting.limit, setting.windowSeconds, actorId); + } + })(); +} + +export function enableRaidMode(actorId: number) { + if (!raidModeActive()) saveRaidModeSnapshot(currentRaidModeOverrides()); + saveRateLimitSettings( + raidModeActionNames.map((action) => ({ + action, + limit: 0, + windowSeconds: rateLimitPolicyFor(action).windowSeconds + })), + actorId + ); +} + +export function disableRaidMode() { + const snapshot = readRaidModeSnapshot(); + const placeholders = raidModeActionNames.map(() => "?").join(", "); + sqlite.transaction(() => { + if (snapshot) { + sqlite.prepare(`DELETE FROM rate_limit_settings WHERE action IN (${placeholders})`).run(...raidModeActionNames); + const restore = sqlite.prepare( + `INSERT INTO rate_limit_settings (action, limit_count, window_seconds, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP)` + ); + for (const setting of snapshot) restore.run(setting.action, setting.limit, setting.windowSeconds); + } else { + sqlite.prepare(`DELETE FROM rate_limit_settings WHERE action IN (${placeholders}) AND limit_count = 0`).run(...raidModeActionNames); + } + deleteSetting(raidModeSnapshotKey); + })(); +} + +export function raidModeActive() { + const placeholders = raidModeActionNames.map(() => "?").join(", "); + const row = sqlite + .prepare(`SELECT COUNT(*) AS count FROM rate_limit_settings WHERE action IN (${placeholders}) AND limit_count = 0`) + .get(...raidModeActionNames) as { count: number }; + return row.count === raidModeActionNames.length; +} + +export function resetRateLimitSettings() { + sqlite.transaction(() => { + sqlite.prepare("DELETE FROM rate_limit_settings").run(); + deleteSetting(raidModeSnapshotKey); + })(); +} + +export const rateLimitActionNames = Object.keys(rateLimits.actions).filter(rateLimitAction); +const raidModeActionNames = rateLimitActionNames.filter((action) => action !== "auth.login" && action !== "auth.reset" && action !== "staff.write"); +const raidModeActions = new Set(raidModeActionNames); + +function currentRaidModeOverrides() { + const placeholders = raidModeActionNames.map(() => "?").join(", "); + const rows = sqlite + .prepare( + `SELECT action, limit_count AS limitValue, window_seconds AS windowSeconds + FROM rate_limit_settings WHERE action IN (${placeholders})` + ) + .all(...raidModeActionNames) as Array<{ action: RateLimitAction; limitValue: number; windowSeconds: number }>; + return rows.map((row) => ({ + action: row.action, + limit: row.limitValue, + windowSeconds: row.windowSeconds + })); +} + +function saveRaidModeSnapshot(settings: StoredRateLimitSetting[]) { + saveSetting(raidModeSnapshotKey, JSON.stringify(settings)); +} + +function readRaidModeSnapshot() { + const row = settingRow(raidModeSnapshotKey); + if (!row) return null; + try { + return raidModeSnapshot(JSON.parse(row.value)); + } catch { + return null; + } +} + +function raidModeSnapshot(value: unknown) { + if (!Array.isArray(value)) return null; + + const seen = new Set(); + const settings: StoredRateLimitSetting[] = []; + for (const item of value) { + const setting = raidModeSnapshotSetting(item); + if (!setting || seen.has(setting.action)) return null; + seen.add(setting.action); + settings.push(setting); + } + return settings; +} + +function raidModeSnapshotSetting(value: unknown): StoredRateLimitSetting | null { + const { action, limit, windowSeconds } = recordFromUnknown(value); + if (!rateLimitAction(action) || !raidModeActions.has(action)) return null; + if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) return null; + if (typeof windowSeconds !== "number" || !Number.isSafeInteger(windowSeconds) || windowSeconds < 1) return null; + return { action, limit, windowSeconds }; +} + +function rateLimitAction(value: unknown): value is RateLimitAction { + return typeof value === "string" && value in rateLimits.actions; +} diff --git a/src/server/db/relationships.test.ts b/src/server/db/relationships.test.ts new file mode 100644 index 0000000..fc05e2b --- /dev/null +++ b/src/server/db/relationships.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-relationships-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + process.env.BLIISH_ADMIN_USER_ID = "999999"; + + const client = await import("./client.js"); + db = client.sqlite; + const schema = await import("./schema.js"); + schema.initializeDatabase(); + + return { + relationships: await import("./relationships.js"), + users: await import("./users.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; + delete process.env.BLIISH_ADMIN_USER_ID; +}); + +describe("relationships", () => { + it("accepts an incoming pending request when the receiver sends a reciprocal request", async () => { + const { relationships, users } = await loadIsolatedDb(); + const senderId = users.createUser({ username: "Sender", email: "sender@example.test", handle: "sender", passwordHash: "hash" }); + const receiverId = users.createUser({ username: "Receiver", email: "receiver@example.test", handle: "receiver", passwordHash: "hash" }); + + expect(relationships.requestFriend(senderId, receiverId)).toBe("requested"); + expect(relationships.requestFriend(receiverId, senderId)).toBe("accepted"); + + expect(relationships.friendshipBetween(senderId, receiverId)).toMatchObject({ + sender_id: senderId, + receiver_id: receiverId, + status: "ACCEPTED" + }); + expect(relationships.pendingRequestsFor(receiverId)).toEqual([]); + expect(relationships.sentRequestsFor(senderId)).toEqual([]); + expect(relationships.friendsFor(senderId).map((person) => person.id)).toEqual([receiverId]); + }); +}); diff --git a/src/server/db/relationships.ts b/src/server/db/relationships.ts new file mode 100644 index 0000000..9aefbcb --- /dev/null +++ b/src/server/db/relationships.ts @@ -0,0 +1,236 @@ +import { sqlite } from "./client.js"; +import { friendshipStatus, limits } from "../../policy.js"; +import { profileVisibilitySql } from "./profileVisibility.js"; +import { personCardRows } from "./personCards.js"; +import { env } from "../env.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { Friendship } from "../../models.js"; + +export function friendshipBetween(a: number, b: number) { + return sqlite + .prepare( + `SELECT id, sender_id, receiver_id, status FROM friendships + WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?) + LIMIT 1` + ) + .get(a, b, b, a) as Friendship | undefined; +} + +export function requestFriend(senderId: number, receiverId: number) { + if (senderId === receiverId) return; + if (isProtectedAdminFriendship(senderId, receiverId)) { + ensureProtectedAdminFriendship(senderId === env.adminUserId ? receiverId : senderId); + return; + } + if (isBlockedBetween(senderId, receiverId)) return; + const existing = friendshipBetween(senderId, receiverId); + if (existing) { + if (existing.status === friendshipStatus.pending && existing.sender_id === receiverId && existing.receiver_id === senderId) { + return acceptFriend(receiverId, senderId) ? "accepted" : undefined; + } + return; + } + sqlite.prepare("INSERT INTO friendships (sender_id, receiver_id, status) VALUES (?, ?, ?)").run(senderId, receiverId, friendshipStatus.pending); + return "requested"; +} + +export function acceptFriend(senderId: number, receiverId: number) { + return sqlite + .prepare( + "UPDATE friendships SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE sender_id = ? AND receiver_id = ? AND status = ?" + ) + .run(friendshipStatus.accepted, senderId, receiverId, friendshipStatus.pending).changes > 0; +} + +export function removeFriend(a: number, b: number) { + if (isProtectedAdminFriendship(a, b)) { + ensureProtectedAdminFriendship(a === env.adminUserId ? b : a); + return false; + } + return sqlite + .prepare("DELETE FROM friendships WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)") + .run(a, b, b, a).changes > 0; +} + +export function friendsFor(userId: number, limit = limits.listPage) { + return personCardRows( + `FROM friendships f + JOIN users u ON u.id = CASE WHEN f.sender_id = ? THEN f.receiver_id ELSE f.sender_id END + JOIN profiles p ON p.user_id = u.id + WHERE (f.sender_id = ? OR f.receiver_id = ?) AND f.status = ? AND u.banned_at IS NULL + ORDER BY f.updated_at DESC LIMIT ?`, + userId, + userId, + userId, + friendshipStatus.accepted, + limit + ); +} + +export function visibleFriendsFor(userId: number, viewer: CurrentUser | null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + return personCardRows( + `FROM friendships f + JOIN users u ON u.id = CASE WHEN f.sender_id = ? THEN f.receiver_id ELSE f.sender_id END + JOIN profiles p ON p.user_id = u.id + WHERE (f.sender_id = ? OR f.receiver_id = ?) AND f.status = ? AND ${visible.sql} + ORDER BY f.updated_at DESC LIMIT ?`, + userId, + userId, + userId, + friendshipStatus.accepted, + ...visible.params, + limit + ); +} + +export function friendCountFor(userId: number, viewer: CurrentUser | null = null) { + const visible = profileVisibilitySql(viewer); + return ( + sqlite + .prepare( + `SELECT COUNT(*) AS count FROM friendships f + JOIN users u ON u.id = CASE WHEN f.sender_id = ? THEN f.receiver_id ELSE f.sender_id END + JOIN profiles p ON p.user_id = u.id + WHERE (f.sender_id = ? OR f.receiver_id = ?) AND f.status = ? AND ${visible.sql}` + ) + .get(userId, userId, userId, friendshipStatus.accepted, ...visible.params) as { count: number } + ).count; +} + +export function pendingRequestsFor(userId: number, limit = limits.listPage) { + return personCardRows( + `FROM friendships f + JOIN users u ON u.id = f.sender_id + JOIN profiles p ON p.user_id = u.id + WHERE f.receiver_id = ? AND f.status = ? AND u.banned_at IS NULL + ORDER BY f.created_at DESC LIMIT ?`, + userId, + friendshipStatus.pending, + limit + ); +} + +export function addFavorite(userId: number, favoriteId: number) { + if (userId === favoriteId) return false; + return sqlite.prepare("INSERT OR IGNORE INTO favorites (user_id, favorite_id) VALUES (?, ?)").run(userId, favoriteId).changes > 0; +} + +export function favoriteUsers(userId: number, viewer: CurrentUser | null = null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + return personCardRows( + `FROM favorites f + JOIN users u ON u.id = f.favorite_id + JOIN profiles p ON p.user_id = u.id + WHERE f.user_id = ? AND ${visible.sql} ORDER BY f.created_at DESC LIMIT ?`, + userId, + ...visible.params, + limit + ); +} + +export function removeFavorite(userId: number, favoriteId: number) { + sqlite.prepare("DELETE FROM favorites WHERE user_id = ? AND favorite_id = ?").run(userId, favoriteId); +} + +export function sentRequestsFor(userId: number, limit = limits.listPage) { + return personCardRows( + `FROM friendships f + JOIN users u ON u.id = f.receiver_id + JOIN profiles p ON p.user_id = u.id + WHERE f.sender_id = ? AND f.status = ? AND u.banned_at IS NULL + ORDER BY f.created_at DESC LIMIT ?`, + userId, + friendshipStatus.pending, + limit + ); +} + +export function blockUser(blockerId: number, blockedId: number) { + if (blockerId === blockedId) return; + if (isProtectedAdminFriendship(blockerId, blockedId)) { + ensureProtectedAdminFriendship(blockerId === env.adminUserId ? blockedId : blockerId); + return; + } + sqlite.transaction(() => { + sqlite.prepare("INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)").run(blockerId, blockedId); + removeFriend(blockerId, blockedId); + })(); +} + +export function unblockUser(blockerId: number, blockedId: number) { + sqlite.prepare("DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?").run(blockerId, blockedId); +} + +export function blockedUsers(userId: number, limit = limits.listPage) { + return personCardRows( + `FROM user_blocks b + JOIN users u ON u.id = b.blocked_id + JOIN profiles p ON p.user_id = u.id + WHERE b.blocker_id = ? + ORDER BY b.created_at DESC LIMIT ?`, + userId, + limit + ); +} + +export function isBlockedBetween(a: number, b: number) { + return Boolean( + sqlite + .prepare( + `SELECT 1 FROM user_blocks + WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?) + LIMIT 1` + ) + .get(a, b, b, a) + ); +} + +export function hasBlocked(blockerId: number, blockedId: number) { + return Boolean(sqlite.prepare("SELECT 1 FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?").get(blockerId, blockedId)); +} + +function isProtectedAdminFriendship(a: number, b: number) { + return a !== b && (a === env.adminUserId || b === env.adminUserId); +} + +export function ensureProtectedAdminFriendship(userId: number) { + if (userId === env.adminUserId) return; + sqlite.transaction(() => { + sqlite + .prepare("DELETE FROM user_blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)") + .run(env.adminUserId, userId, userId, env.adminUserId); + sqlite + .prepare( + `INSERT OR IGNORE INTO friendships (sender_id, receiver_id, status) + SELECT ?, u.id, ? FROM users u + WHERE u.id = ? AND u.id <> ? AND EXISTS (SELECT 1 FROM users admin WHERE admin.id = ?)` + ) + .run(env.adminUserId, friendshipStatus.accepted, userId, env.adminUserId, env.adminUserId); + sqlite + .prepare( + `UPDATE friendships SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE status <> ? AND ((sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?))` + ) + .run(friendshipStatus.accepted, friendshipStatus.accepted, env.adminUserId, userId, userId, env.adminUserId); + })(); +} + +export function ensureProtectedAdminFriendships() { + if (!sqlite.prepare("SELECT 1 FROM users WHERE id = ?").get(env.adminUserId)) return; + sqlite.transaction(() => { + sqlite.prepare("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?").run(env.adminUserId, env.adminUserId); + sqlite + .prepare( + `INSERT OR IGNORE INTO friendships (sender_id, receiver_id, status) + SELECT ?, u.id, ? FROM users u WHERE u.id <> ?` + ) + .run(env.adminUserId, friendshipStatus.accepted, env.adminUserId); + sqlite + .prepare( + `UPDATE friendships SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE status <> ? AND (sender_id = ? OR receiver_id = ?)` + ) + .run(friendshipStatus.accepted, friendshipStatus.accepted, env.adminUserId, env.adminUserId); + })(); +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 0000000..48c2b6b --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,447 @@ +import { sqlite } from "./client.js"; +import { ensureRuntimeDirs } from "../env.js"; +import { installDefaultAutomodRules } from "../moderation/automodDefaults.js"; +import { blogCategories, defaultBlogCategory } from "../../policy.js"; +import { ensureDefaultGroupMemberships } from "./groups.js"; +import { ensureProtectedAdminFriendships } from "./relationships.js"; +import { installBuiltinSkins } from "./skins.js"; +import { + notificationContextTypeNames, + notificationKindNames, + notificationPreferenceTypeNames, + notificationPreferenceTypes, + notificationKinds, + notificationSubjectTypeNames +} from "../../notifications.js"; + +const sqlString = (value: string) => `'${value.replace(/'/g, "''")}'`; +const blogCategoryCheck = blogCategories.map(sqlString).join(", "); +const notificationKindCheck = notificationKindNames.map(sqlString).join(", "); +const notificationPreferenceTypeCheck = notificationPreferenceTypeNames.map(sqlString).join(", "); +const notificationSubjectTypeCheck = notificationSubjectTypeNames.map(sqlString).join(", "); +const notificationContextTypeCheck = notificationContextTypeNames.map(sqlString).join(", "); + +const schemaSql = ` +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'moderator', 'admin')), + username TEXT NOT NULL, + email TEXT NOT NULL, + email_canonical TEXT NOT NULL, + password_hash TEXT NOT NULL, + time_zone TEXT NOT NULL DEFAULT 'UTC', + verified_at TEXT, + banned_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users(email); +CREATE UNIQUE INDEX IF NOT EXISTS users_email_canonical_idx ON users(email_canonical); +CREATE INDEX IF NOT EXISTS users_username_idx ON users(username); +CREATE INDEX IF NOT EXISTS users_created_idx ON users(created_at); + +CREATE TABLE IF NOT EXISTS handle_reservations ( + handle TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS handle_reservations_user_idx ON handle_reservations(user_id); + +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description_html TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS groups_owner_idx ON groups(owner_id); + +CREATE TABLE IF NOT EXISTS profiles ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + bio_html TEXT NOT NULL DEFAULT '', + skin_html TEXT NOT NULL DEFAULT '', + interests_json TEXT NOT NULL, + social_links_json TEXT NOT NULL DEFAULT '{}', + status_json TEXT NOT NULL, + pfp TEXT NOT NULL DEFAULT 'default', + theme_song TEXT NOT NULL DEFAULT 'default.mp3', + current_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + private INTEGER NOT NULL DEFAULT 0 CHECK (private IN (0, 1)), + views INTEGER NOT NULL DEFAULT 0 CHECK (views >= 0), + handle TEXT NOT NULL REFERENCES handle_reservations(handle) +); +CREATE UNIQUE INDEX IF NOT EXISTS profiles_handle_idx ON profiles(handle); +CREATE INDEX IF NOT EXISTS profiles_pfp_idx ON profiles(pfp); +CREATE INDEX IF NOT EXISTS profiles_theme_song_idx ON profiles(theme_song); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL, + csrf_token TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS sessions_token_idx ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS rate_limit_counters ( + action TEXT NOT NULL, + subject_hash TEXT NOT NULL, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (action, subject_hash, window_start) +); +CREATE INDEX IF NOT EXISTS rate_limit_counters_window_idx ON rate_limit_counters(window_start); +CREATE INDEX IF NOT EXISTS rate_limit_counters_updated_idx ON rate_limit_counters(updated_at); + +CREATE TABLE IF NOT EXISTS rate_limit_settings ( + action TEXT PRIMARY KEY, + limit_count INTEGER NOT NULL CHECK (limit_count >= 0), + window_seconds INTEGER NOT NULL CHECK (window_seconds >= 1), + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS rate_limit_settings_updated_idx ON rate_limit_settings(updated_at); + +CREATE TABLE IF NOT EXISTS friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'ACCEPTED')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (sender_id <> receiver_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS friendships_pair_idx ON friendships(sender_id, receiver_id); +-- Unordered-pair uniqueness: blocks a second row with sender and receiver +-- swapped, so A->B and B->A cannot both exist. +CREATE UNIQUE INDEX IF NOT EXISTS friendships_unordered_pair_idx ON friendships(min(sender_id, receiver_id), max(sender_id, receiver_id)); +CREATE INDEX IF NOT EXISTS friendships_sender_status_created_idx ON friendships(sender_id, status, created_at); +CREATE INDEX IF NOT EXISTS friendships_receiver_status_created_idx ON friendships(receiver_id, status, created_at); + +CREATE TABLE IF NOT EXISTS favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + favorite_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, favorite_id) +); +CREATE INDEX IF NOT EXISTS favorites_favorite_idx ON favorites(favorite_id, created_at); + +CREATE TABLE IF NOT EXISTS blogs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body_html TEXT NOT NULL, + category TEXT NOT NULL DEFAULT ${sqlString(defaultBlogCategory)} CHECK (category IN (${blogCategoryCheck})), + privacy_level INTEGER NOT NULL DEFAULT 0 CHECK (privacy_level IN (0, 1, 2)), + pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), + comments_enabled INTEGER NOT NULL DEFAULT 1 CHECK (comments_enabled IN (0, 1)), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS blogs_author_idx ON blogs(author_id); +CREATE INDEX IF NOT EXISTS blogs_feed_idx ON blogs(pinned, created_at); +CREATE INDEX IF NOT EXISTS blogs_category_idx ON blogs(category, pinned, created_at); + +CREATE TABLE IF NOT EXISTS blog_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blog_id INTEGER NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text_html TEXT NOT NULL, + parent_id INTEGER REFERENCES blog_comments(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS blog_comments_blog_idx ON blog_comments(blog_id); +CREATE INDEX IF NOT EXISTS blog_comments_blog_created_idx ON blog_comments(blog_id, created_at); + +CREATE TABLE IF NOT EXISTS blog_props ( + blog_id INTEGER NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (blog_id, user_id) +); +CREATE INDEX IF NOT EXISTS blog_props_user_idx ON blog_props(user_id, created_at); + +CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'owner')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_id, user_id) +); +CREATE INDEX IF NOT EXISTS group_members_user_idx ON group_members(user_id); + +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + wall_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + body_html TEXT NOT NULL, + media_filename TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ((wall_user_id IS NOT NULL) <> (group_id IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS posts_author_idx ON posts(author_id, created_at); +CREATE INDEX IF NOT EXISTS posts_wall_idx ON posts(wall_user_id, created_at); +CREATE INDEX IF NOT EXISTS posts_group_idx ON posts(group_id, created_at); +CREATE INDEX IF NOT EXISTS posts_created_idx ON posts(created_at); +CREATE INDEX IF NOT EXISTS posts_media_filename_idx ON posts(media_filename); + +CREATE TABLE IF NOT EXISTS post_props ( + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (post_id, user_id) +); +CREATE INDEX IF NOT EXISTS post_props_user_idx ON post_props(user_id, created_at); + +CREATE TABLE IF NOT EXISTS post_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text_html TEXT NOT NULL, + parent_id INTEGER REFERENCES post_comments(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS post_comments_post_idx ON post_comments(post_id, created_at); +CREATE INDEX IF NOT EXISTS post_comments_author_created_idx ON post_comments(author_id, created_at); + +CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + subject_author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + subject_type TEXT NOT NULL, + subject_id INTEGER NOT NULL, + reason_html TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TEXT, + resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS reports_subject_idx ON reports(subject_type, subject_id); +CREATE INDEX IF NOT EXISTS reports_unresolved_idx ON reports(resolved_at); +CREATE INDEX IF NOT EXISTS reports_created_idx ON reports(created_at); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + body_html TEXT NOT NULL, + read_at TEXT, + sender_deleted INTEGER NOT NULL DEFAULT 0 CHECK (sender_deleted IN (0, 1)), + receiver_deleted INTEGER NOT NULL DEFAULT 0 CHECK (receiver_deleted IN (0, 1)), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS messages_receiver_visible_idx ON messages(receiver_id, receiver_deleted, created_at, id); +CREATE INDEX IF NOT EXISTS messages_sender_visible_idx ON messages(sender_id, sender_deleted, created_at, id); + +CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN (${notificationPreferenceTypeCheck})), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, type) +); + +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN (${notificationKindCheck})), + subject_type TEXT NOT NULL CHECK (subject_type IN (${notificationSubjectTypeCheck})), + subject_id INTEGER NOT NULL, + context_type TEXT NOT NULL CHECK (context_type IN (${notificationContextTypeCheck})), + context_id INTEGER NOT NULL, + read_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (recipient_id <> actor_id) +); +CREATE INDEX IF NOT EXISTS notifications_recipient_created_idx ON notifications(recipient_id, created_at, id); +CREATE INDEX IF NOT EXISTS notifications_recipient_unread_idx ON notifications(recipient_id, read_at, created_at); +CREATE INDEX IF NOT EXISTS notifications_context_idx ON notifications(context_type, context_id, created_at); + +CREATE TRIGGER IF NOT EXISTS notifications_wall_posts_insert +AFTER INSERT ON posts +WHEN NEW.wall_user_id IS NOT NULL + AND NEW.wall_user_id <> NEW.author_id + AND NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = NEW.wall_user_id AND b.blocked_id = NEW.author_id) + OR (b.blocker_id = NEW.author_id AND b.blocked_id = NEW.wall_user_id) + ) + AND NOT EXISTS ( + SELECT 1 FROM notification_preferences np + WHERE np.user_id = NEW.wall_user_id + AND np.type = ${sqlString(notificationPreferenceTypes.wallPosts)} + AND np.enabled = 0 + ) +BEGIN + INSERT INTO notifications ( + recipient_id, actor_id, kind, subject_type, subject_id, context_type, context_id + ) VALUES ( + NEW.wall_user_id, NEW.author_id, ${sqlString(notificationKinds.wallPost)}, 'post', NEW.id, 'post', NEW.id + ); +END; + +CREATE TRIGGER IF NOT EXISTS notifications_posts_delete +AFTER DELETE ON posts +BEGIN + DELETE FROM notifications + WHERE (context_type = 'post' AND context_id = OLD.id) + OR (subject_type = 'post' AND subject_id = OLD.id); +END; + +CREATE TRIGGER IF NOT EXISTS notifications_post_comments_delete +AFTER DELETE ON post_comments +BEGIN + DELETE FROM notifications + WHERE subject_type = 'post_comment' AND subject_id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS notifications_blogs_delete +AFTER DELETE ON blogs +BEGIN + DELETE FROM notifications + WHERE (context_type = 'blog' AND context_id = OLD.id) + OR (subject_type = 'blog' AND subject_id = OLD.id); +END; + +CREATE TRIGGER IF NOT EXISTS notifications_blog_comments_delete +AFTER DELETE ON blog_comments +BEGIN + DELETE FROM notifications + WHERE subject_type = 'blog_comment' AND subject_id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS notifications_groups_delete +BEFORE DELETE ON groups +BEGIN + DELETE FROM notifications + WHERE context_type = 'post' AND context_id IN (SELECT id FROM posts WHERE group_id = OLD.id); +END; + +CREATE TABLE IF NOT EXISTS skins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_key TEXT, + author_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description_html TEXT NOT NULL, + code_html TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS skins_author_idx ON skins(author_id); +CREATE INDEX IF NOT EXISTS skins_updated_idx ON skins(updated_at); +CREATE UNIQUE INDEX IF NOT EXISTS skins_source_key_idx ON skins(source_key); + +CREATE TABLE IF NOT EXISTS skin_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + skin_id INTEGER NOT NULL REFERENCES skins(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text_html TEXT NOT NULL, + parent_id INTEGER REFERENCES skin_comments(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS skin_comments_skin_idx ON skin_comments(skin_id); +CREATE INDEX IF NOT EXISTS skin_comments_skin_created_idx ON skin_comments(skin_id, created_at); + +CREATE TRIGGER IF NOT EXISTS notifications_skins_delete +AFTER DELETE ON skins +BEGIN + DELETE FROM notifications + WHERE (context_type = 'skin' AND context_id = OLD.id) + OR (subject_type = 'skin' AND subject_id = OLD.id); +END; + +CREATE TRIGGER IF NOT EXISTS notifications_skin_comments_delete +AFTER DELETE ON skin_comments +BEGIN + DELETE FROM notifications + WHERE subject_type = 'skin_comment' AND subject_id = OLD.id; +END; + +CREATE TABLE IF NOT EXISTS user_blocks ( + blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (blocker_id, blocked_id), + CHECK (blocker_id <> blocked_id) +); +CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id, blocker_id); + +CREATE TABLE IF NOT EXISTS reset_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + used_at TEXT +); +CREATE INDEX IF NOT EXISTS reset_tokens_user_idx ON reset_tokens(user_id); + +CREATE TABLE IF NOT EXISTS verification_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + used_at TEXT +); +CREATE INDEX IF NOT EXISTS verification_tokens_user_idx ON verification_tokens(user_id); + +CREATE TABLE IF NOT EXISTS email_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + to_email TEXT NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + sent_at TEXT, + delivery_error TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS email_outbox_created_idx ON email_outbox(created_at); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + subject_type TEXT NOT NULL, + subject_id INTEGER NOT NULL, + reason_html TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS audit_log_subject_idx ON audit_log(subject_type, subject_id); +CREATE INDEX IF NOT EXISTS audit_log_created_idx ON audit_log(created_at); + +CREATE TABLE IF NOT EXISTS automod_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + pattern TEXT NOT NULL, + pattern_type TEXT NOT NULL DEFAULT 'keyword' CHECK (pattern_type IN ('keyword', 'regex')), + scope TEXT NOT NULL DEFAULT 'all' CHECK (scope IN ('all', 'profile', 'blog', 'post', 'comment', 'group', 'skin', 'message')), + action TEXT NOT NULL DEFAULT 'review' CHECK (action IN ('review', 'reject')), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS automod_rules_enabled_idx ON automod_rules(enabled, scope); +CREATE INDEX IF NOT EXISTS automod_rules_updated_idx ON automod_rules(updated_at); +`; + +export function initializeDatabase() { + ensureRuntimeDirs(); + sqlite.exec(schemaSql); + installDefaultAutomodRules(); + installBuiltinSkins(); + ensureProtectedAdminFriendships(); + ensureDefaultGroupMemberships(); +} diff --git a/src/server/db/search.ts b/src/server/db/search.ts new file mode 100644 index 0000000..818d3a9 --- /dev/null +++ b/src/server/db/search.ts @@ -0,0 +1,27 @@ +import type { CurrentUser } from "../../currentUser.js"; +import type { BlogListItem, GroupItem, PersonCard, PostItem, SkinItem } from "../../models.js"; +import { limits } from "../../policy.js"; +import { searchBlogs } from "./blogs/index.js"; +import { searchGroups } from "./groups.js"; +import { searchPosts } from "./posts/index.js"; +import { searchSkins } from "./skins.js"; +import { searchUsers } from "./users.js"; + +export type SiteSearchResults = { + people: PersonCard[]; + blogs: BlogListItem[]; + groups: GroupItem[]; + posts: PostItem[]; + skins: SkinItem[]; +}; + +export function searchSite(query: string, viewer: CurrentUser | null): SiteSearchResults { + const limit = limits.searchResults; + return { + people: searchUsers(query, viewer, limit), + blogs: searchBlogs(query, viewer, limit).blogs, + groups: viewer ? searchGroups(query, viewer, limit) : [], + posts: searchPosts(query, viewer, limit), + skins: searchSkins(query, viewer, limit) + }; +} diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts new file mode 100644 index 0000000..e8cd5ac --- /dev/null +++ b/src/server/db/settings.ts @@ -0,0 +1,30 @@ +import { sqlite } from "./client.js"; + +type SettingRow = { + value: string; + updatedAt: string; +}; + +export function settingRow(key: string) { + return sqlite + .prepare("SELECT value, updated_at AS updatedAt FROM app_settings WHERE key = ?") + .get(key) as SettingRow | undefined; +} + +export function settingExists(key: string) { + return Boolean(sqlite.prepare("SELECT 1 FROM app_settings WHERE key = ?").get(key)); +} + +export function saveSetting(key: string, value: string) { + sqlite + .prepare( + `INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP` + ) + .run(key, value); +} + +export function deleteSetting(key: string) { + sqlite.prepare("DELETE FROM app_settings WHERE key = ?").run(key); +} diff --git a/src/server/db/siteSettings.ts b/src/server/db/siteSettings.ts new file mode 100644 index 0000000..c2a6bcd --- /dev/null +++ b/src/server/db/siteSettings.ts @@ -0,0 +1,161 @@ +import { limits, validEmail } from "../../policy.js"; +import { + defaultHeaderIconName, + defaultHeaderIconSvg, + defaultSiteSettings, + type SiteContactSettings, + type SiteHomeSettings, + type SiteIdentitySettings, + type SiteSettings +} from "../../settings/site.js"; +import { recordFromUnknown, stringFromUnknown } from "../../values.js"; +import { saveSetting, settingRow } from "./settings.js"; + +type StoredSiteSettings = Omit; + +export class SiteSettingsValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "SiteSettingsValidationError"; + } +} + +const siteSettingsKey = "site.settings"; +const lucideIconPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function siteSettings(): SiteSettings { + const row = settingRow(siteSettingsKey); + const settings = parseSiteSettingsJson(row?.value); + return { ...settings, updatedAt: row?.updatedAt ?? null }; +} + +export function saveSiteIdentity(identity: SiteIdentitySettings) { + saveSiteSettings({ ...siteSettings(), identity: normalizeIdentity(identity) }); +} + +export function saveSiteHome(home: SiteHomeSettings) { + saveSiteSettings({ ...siteSettings(), home: normalizeHome(home) }); +} + +export function saveSiteContact(contact: SiteContactSettings) { + saveSiteSettings({ ...siteSettings(), contact: normalizeContact(contact) }); +} + +export async function siteIconFromName(value: string) { + const name = normalizeIconName(value); + if (!name) throw new SiteSettingsValidationError("Use a Lucide icon name like users or message-circle."); + if (name === defaultHeaderIconName) return { name, svg: defaultHeaderIconSvg }; + + try { + const icon = await import(`lucide-static/dist/esm/icons/${name}.mjs`) as { default?: unknown }; + if (typeof icon.default !== "string" || !icon.default.includes(", key: string, maxLength: number, fallback: string) { + return key in record ? cleanText(record[key], maxLength) : fallback; +} + +function cleanSvg(value: unknown) { + const svg = stringFromUnknown(value).trim(); + if (!svg.startsWith("") || svg.length > 5000) return ""; + if (/<(?:script|style|foreignObject|iframe|image|use|a)\b/i.test(svg)) return ""; + if (/\son[a-z]+\s*=|(?:href|src)\s*=|javascript:|data:/i.test(svg)) return ""; + return svg; +} + +function normalizeIconName(value: unknown) { + const normalized = stringFromUnknown(value) + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, "-") + .toLowerCase() + .slice(0, limits.shortText); + return lucideIconPattern.test(normalized) ? normalized : ""; +} diff --git a/src/server/db/skins.test.ts b/src/server/db/skins.test.ts new file mode 100644 index 0000000..190704c --- /dev/null +++ b/src/server/db/skins.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-skins-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("./client.js"); + db = client.sqlite; + const schema = await import("./schema.js"); + schema.initializeDatabase(); + + return { + comments: await import("./comments.js"), + skins: await import("./skins.js"), + users: await import("./users.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("skins", () => { + it("installs builtin skins in curated order with visible original credits", async () => { + const { skins } = await loadIsolatedDb(); + const builtinSkins = skins.listSkins(null, 50).filter((skin) => skin.sourceKey); + + expect(builtinSkins.slice(0, 2).map((skin) => skin.sourceKey)).toEqual(["bliish.light", "bliish.dark"]); + expect(builtinSkins).toHaveLength(17); + + const blackAndGrey = builtinSkins.find((skin) => skin.sourceKey === "spacehey.black-and-grey"); + expect(blackAndGrey?.username).toBe(""); + expect(blackAndGrey?.descriptionHtml).toContain("Original SpaceHey layout credit"); + expect(blackAndGrey?.descriptionHtml).toContain("Bela"); + expect(blackAndGrey?.descriptionHtml).toContain("https://layouts.spacehey.com/layout?id=2719"); + expect(blackAndGrey?.codeHtml).not.toContain("Original SpaceHey layout credit"); + }); + + it("hides skins authored by banned users", async () => { + const { skins, users } = await loadIsolatedDb(); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const bannedId = users.createUser({ username: "Banned", email: "banned@example.test", handle: "banned", passwordHash: "hash" }); + const visibleSkinId = skins.createSkin( + authorId, + "Visible", + "description", + '' + ); + const hiddenSkinId = skins.createSkin( + bannedId, + "Hidden skin", + "description", + '' + ); + + users.setUserBanned(bannedId, true); + + expect(skins.listSkins(null, 100).filter((skin) => !skin.sourceKey).map((skin) => skin.id)).toEqual([visibleSkinId]); + expect(skins.getSkin(visibleSkinId)?.id).toBe(visibleSkinId); + expect(skins.getSkin(hiddenSkinId)).toBeUndefined(); + }); + + it("sorts community skins by visible comments before recency", async () => { + const { comments, skins, users } = await loadIsolatedDb(); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const commenterId = users.createUser({ username: "Commenter", email: "commenter@example.test", handle: "commenter", passwordHash: "hash" }); + const newestSkinId = skins.createSkin( + authorId, + "Newest", + "description", + '' + ); + const discussedSkinId = skins.createSkin( + authorId, + "Discussed", + "description", + '' + ); + + db?.prepare("UPDATE skins SET updated_at = ? WHERE id = ?").run("2026-01-02 00:00:00", newestSkinId); + db?.prepare("UPDATE skins SET updated_at = ? WHERE id = ?").run("2026-01-01 00:00:00", discussedSkinId); + comments.addComment("skin", discussedSkinId, commenterId, "nice"); + + const communitySkins = skins.listSkins(null, 100).filter((skin) => !skin.sourceKey); + expect(communitySkins.map((skin) => skin.id)).toEqual([discussedSkinId, newestSkinId]); + expect(communitySkins.map((skin) => skin.commentCount)).toEqual([1, 0]); + }); +}); diff --git a/src/server/db/skins.ts b/src/server/db/skins.ts new file mode 100644 index 0000000..50fc0f2 --- /dev/null +++ b/src/server/db/skins.ts @@ -0,0 +1,135 @@ +import { sqlite } from "./client.js"; +import { limits } from "../../policy.js"; +import { addComment, commentsFor, deleteComment } from "./comments.js"; +import { profileVisibilitySql } from "./profileVisibility.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { SkinItem } from "../../models.js"; +import { containsLikePattern, likeEscapeClause } from "./like.js"; +import { builtinSkinDefinitions, builtinSkinOrderSql } from "../../skins/builtin.js"; + +const skinColumns = `s.id, s.source_key AS sourceKey, s.title, s.description_html AS descriptionHtml, s.code_html AS codeHtml, + s.created_at AS createdAt, s.updated_at AS updatedAt, u.id AS authorId, + COALESCE(u.role, 'user') AS authorRole, COALESCE(p.handle, '') AS authorHandle, COALESCE(u.username, '') AS username`; + +const skinCommentCountSql = (commentVisibilitySql: string, parentCommentVisibilitySql: string) => `( + SELECT COUNT(*) + FROM skin_comments comments + JOIN users comment_author ON comment_author.id = comments.author_id + JOIN profiles comment_profile ON comment_profile.user_id = comment_author.id + WHERE comments.skin_id = s.id + AND ${commentVisibilitySql} + AND ( + comments.parent_id IS NULL OR EXISTS ( + SELECT 1 FROM skin_comments parent_comment + JOIN users parent_author ON parent_author.id = parent_comment.author_id + JOIN profiles parent_profile ON parent_profile.user_id = parent_author.id + WHERE parent_comment.id = comments.parent_id + AND parent_comment.skin_id = comments.skin_id + AND ${parentCommentVisibilitySql} + ) + ) +)`; + +export function listSkins(viewer: CurrentUser | null = null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + return skinRows( + viewer, + `WHERE s.source_key IS NOT NULL OR (${visible.sql}) + ORDER BY CASE WHEN s.source_key IS NOT NULL THEN 0 ELSE 1 END, ${builtinSkinOrderSql("s.source_key")}, commentCount DESC, s.updated_at DESC, s.id DESC LIMIT ?`, + ...visible.params, + limit + ); +} + +export function searchSkins(query: string, viewer: CurrentUser | null = null, limit = limits.listPage) { + const pattern = containsLikePattern(query); + const visible = profileVisibilitySql(viewer); + return skinRows( + viewer, + `WHERE (s.source_key IS NOT NULL OR (${visible.sql})) AND (s.title LIKE ? ${likeEscapeClause} OR s.description_html LIKE ? ${likeEscapeClause}) + ORDER BY CASE WHEN s.source_key IS NOT NULL THEN 0 ELSE 1 END, ${builtinSkinOrderSql("s.source_key")}, s.updated_at DESC LIMIT ?`, + ...visible.params, + pattern, + pattern, + limit + ); +} + +export function skinsForUser(userId: number, limit = limits.exportRows) { + return skinRows(null, "WHERE s.author_id = ? AND u.banned_at IS NULL ORDER BY s.updated_at DESC LIMIT ?", userId, limit); +} + +export function getSkin(id: number) { + return skinRows(null, "WHERE s.id = ? AND (s.source_key IS NOT NULL OR (u.id IS NOT NULL AND u.banned_at IS NULL))", id)[0]; +} + +export function createSkin(authorId: number, title: string, descriptionHtml: string, codeHtml: string) { + const info = sqlite + .prepare("INSERT INTO skins (author_id, title, description_html, code_html) VALUES (?, ?, ?, ?)") + .run(authorId, title, descriptionHtml, codeHtml); + return Number(info.lastInsertRowid); +} + +export function installBuiltinSkins() { + const upsert = sqlite.prepare( + `INSERT INTO skins (source_key, title, description_html, code_html) + VALUES (@sourceKey, @title, @descriptionHtml, @codeHtml) + ON CONFLICT(source_key) DO UPDATE SET + author_id = NULL, + title = excluded.title, + description_html = excluded.description_html, + code_html = excluded.code_html, + updated_at = CASE + WHEN skins.author_id IS NULL + AND skins.title = excluded.title + AND skins.description_html = excluded.description_html + AND skins.code_html = excluded.code_html + THEN skins.updated_at + ELSE CURRENT_TIMESTAMP + END` + ); + const install = sqlite.transaction(() => { + for (const skin of builtinSkinDefinitions) upsert.run(skin); + }); + install(); + return true; +} + +export function updateSkin(authorId: number | null, skinId: number, title: string, descriptionHtml: string, codeHtml: string) { + const ownerFilter = authorId === null ? "author_id IS NULL" : "author_id = ?"; + const params = authorId === null ? [title, descriptionHtml, codeHtml, skinId] : [title, descriptionHtml, codeHtml, skinId, authorId]; + const info = sqlite + .prepare(`UPDATE skins SET title = ?, description_html = ?, code_html = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND ${ownerFilter}`) + .run(...params); + return info.changes > 0; +} + +export function deleteSkin(skinId: number, actorId: number, isAdmin = false) { + const info = isAdmin + ? sqlite.prepare("DELETE FROM skins WHERE id = ?").run(skinId) + : sqlite.prepare("DELETE FROM skins WHERE id = ? AND author_id = ?").run(skinId, actorId); + return info.changes > 0; +} + +export function skinCommentsFor(skinId: number, viewer: CurrentUser | null = null, limit?: number) { + return commentsFor("skin", skinId, { viewer, limit }); +} + +export function addSkinComment(skinId: number, authorId: number, textHtml: string, parentId?: number, viewer: CurrentUser | null = null): number | null { + return addComment("skin", skinId, authorId, textHtml, parentId, viewer); +} + +export function deleteSkinComment(commentId: number, actorId: number, isAdmin = false) { + return deleteComment("skin", commentId, actorId, isAdmin); +} + +function skinRows(viewer: CurrentUser | null, tail: string, ...params: unknown[]) { + const visibleComments = profileVisibilitySql(viewer, { user: "comment_author", profile: "comment_profile" }); + const visibleParentComments = profileVisibilitySql(viewer, { user: "parent_author", profile: "parent_profile" }); + return sqlite + .prepare( + `SELECT ${skinColumns}, ${skinCommentCountSql(visibleComments.sql, visibleParentComments.sql)} AS commentCount + FROM skins s LEFT JOIN users u ON u.id = s.author_id LEFT JOIN profiles p ON p.user_id = u.id ${tail}` + ) + .all(...visibleComments.params, ...visibleParentComments.params, ...params) as SkinItem[]; +} diff --git a/src/server/db/staffDashboard.ts b/src/server/db/staffDashboard.ts new file mode 100644 index 0000000..49ac34a --- /dev/null +++ b/src/server/db/staffDashboard.ts @@ -0,0 +1,106 @@ +import { limits } from "../../policy.js"; +import { sqlite } from "./client.js"; +import type { FavoriteEdge, StaffUserRow, TableCount } from "../../models.js"; +import { blogRows } from "./blogs/sql.js"; +import { containsLikePattern, likeEscapeClause } from "./like.js"; + +const staffUserColumns = `u.id, u.username, u.email, u.role, u.verified_at AS verifiedAt, + u.banned_at AS bannedAt, u.created_at AS createdAt, p.handle, p.pfp, p.views`; + +const countedTables = [ + "app_settings", + "users", + "handle_reservations", + "profiles", + "sessions", + "rate_limit_counters", + "rate_limit_settings", + "friendships", + "favorites", + "blogs", + "blog_comments", + "blog_props", + "groups", + "group_members", + "posts", + "post_props", + "post_comments", + "messages", + "notification_preferences", + "notifications", + "skins", + "skin_comments", + "user_blocks", + "reset_tokens", + "verification_tokens", + "reports", + "automod_rules", + "email_outbox", + "audit_log" +]; + +export function staffUserRows(limit = limits.listPage) { + return staffUserRowsFrom("ORDER BY u.created_at DESC LIMIT ?", limit); +} + +export function searchStaffUserRows(query: string, limit = limits.listPage) { + const pattern = containsLikePattern(query); + return staffUserRowsFrom( + `WHERE u.username LIKE ? ${likeEscapeClause} + OR u.email LIKE ? ${likeEscapeClause} + OR CAST(u.id AS TEXT) = ? ORDER BY u.created_at DESC LIMIT ?`, + pattern, + pattern, + query, + limit + ); +} + +function staffUserRowsFrom(tail: string, ...params: unknown[]) { + return sqlite + .prepare(`SELECT ${staffUserColumns} FROM users u JOIN profiles p ON p.user_id = u.id ${tail}`) + .all(...params) as StaffUserRow[]; +} + +export function staffBlogRows(limit = limits.listPage) { + return blogRows("ORDER BY b.created_at DESC LIMIT ?", staffViewer(), limit); +} + +function staffViewer() { + return { + id: 0, + username: "staff", + email: "", + role: "admin", + timeZone: "UTC", + verifiedAt: null, + bannedAt: null + } as const; +} + +export function staffFavoriteEdges(limit = limits.listPage) { + return sqlite + .prepare( + `SELECT owner.id AS userId, owner.username, ownerProfile.handle AS userHandle, + favorite.id AS favoriteId, favorite.username AS favoriteName, favoriteProfile.handle AS favoriteHandle, + f.created_at AS createdAt + FROM favorites f + JOIN users owner ON owner.id = f.user_id + JOIN profiles ownerProfile ON ownerProfile.user_id = owner.id + JOIN users favorite ON favorite.id = f.favorite_id + JOIN profiles favoriteProfile ON favoriteProfile.user_id = favorite.id + ORDER BY f.created_at DESC LIMIT ?` + ) + .all(limit) as FavoriteEdge[]; +} + +export function removeFavoriteEdge(userId: number, favoriteId: number) { + return sqlite.prepare("DELETE FROM favorites WHERE user_id = ? AND favorite_id = ?").run(userId, favoriteId).changes > 0; +} + +export function databaseTableCounts() { + return countedTables.map((name) => { + const row = sqlite.prepare(`SELECT COUNT(*) AS count FROM ${name}`).get() as { count: number }; + return { name, count: row.count }; + }) satisfies TableCount[]; +} diff --git a/src/server/db/users.test.ts b/src/server/db/users.test.ts new file mode 100644 index 0000000..fb1dd7e --- /dev/null +++ b/src/server/db/users.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-users-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("./client.js"); + db = client.sqlite; + const schema = await import("./schema.js"); + schema.initializeDatabase(); + + return { + users: await import("./users.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("users", () => { + it("omits banned accounts from profile discovery", async () => { + const { users } = await loadIsolatedDb(); + users.createUser({ username: "Active", email: "active@example.test", handle: "active", passwordHash: "hash" }); + const bannedId = users.createUser({ username: "Banned", email: "banned@example.test", handle: "banned", passwordHash: "hash" }); + + users.setUserBanned(bannedId, true); + + expect(users.listUsers(null, 10).map((person) => person.id)).not.toContain(bannedId); + expect(users.searchUsers("Banned", null)).toEqual([]); + expect(users.newestUsers(null, 10).map((person) => person.id)).not.toContain(bannedId); + expect(users.getProfile(bannedId)?.bannedAt).toBeTruthy(); + }); + + it("paginates profile discovery in newest-first order", async () => { + const { users } = await loadIsolatedDb(); + const createdIds = ["one", "two", "three", "four", "five"].map((handle) => + users.createUser({ + username: handle, + email: `${handle}@example.test`, + handle, + passwordHash: "hash" + }) + ); + + const firstPage = users.listUsersPage(null, { limit: 2 }); + const secondPage = users.listUsersPage(null, { before: firstPage.nextCursor, limit: 2 }); + const thirdPage = users.listUsersPage(null, { before: secondPage.nextCursor, limit: 2 }); + + expect(firstPage.items.map((person) => person.id)).toEqual([createdIds[4], createdIds[3]]); + expect(firstPage.nextCursor).toBeTruthy(); + expect(secondPage.items.map((person) => person.id)).toEqual([createdIds[2], createdIds[1]]); + expect(secondPage.nextCursor).toBeTruthy(); + expect(thirdPage.items.map((person) => person.id)).toEqual([createdIds[0]]); + expect(thirdPage.nextCursor).toBeNull(); + }); + + it("rejects reserved profile handles", async () => { + const { users } = await loadIsolatedDb(); + users.createUser({ username: "Active", email: "active@example.test", handle: "active", passwordHash: "hash" }); + + for (const handle of ["admin", "staff", "bliish"]) { + expect(users.handleReserved(handle)).toBe(true); + expect(() => + users.createUser({ + username: handle, + email: `${handle}@example.test`, + handle, + passwordHash: "hash" + }) + ).toThrow(users.HandleReservedError); + } + }); + + it("allows the configured admin account to claim a reserved profile handle", async () => { + const { users } = await loadIsolatedDb(); + + const adminId = users.createUser({ + username: "Admin", + email: "admin@example.test", + handle: "admin", + passwordHash: "hash" + }); + + expect(adminId).toBe(1); + expect(users.getProfile(adminId)?.handle).toBe("admin"); + }); + + it("rejects too-short profile handles", async () => { + const { users } = await loadIsolatedDb(); + + expect(() => + users.createUser({ + username: "Short", + email: "short@example.test", + handle: "ab", + passwordHash: "hash" + }) + ).toThrow("Invalid profile handle."); + }); +}); diff --git a/src/server/db/users.ts b/src/server/db/users.ts new file mode 100644 index 0000000..60a22d1 --- /dev/null +++ b/src/server/db/users.ts @@ -0,0 +1,292 @@ +import { sqlite } from "./client.js"; +import { canonicalEmail, defaultMedia, limits, reservedHandle, validHandle } from "../../policy.js"; +import { normalizeRole, type UserRole } from "../../roles.js"; +import { defaultSocialLinks, normalizeStoredSocialLinks, type SocialLinks } from "../../socialLinks.js"; +import { normalizeTimeZone } from "../../timeZones.js"; +import { recordFromUnknown, stringFromUnknown } from "../../values.js"; +import { env } from "../env.js"; +import { decodeKeysetCursor, keysetBeforeCondition, normalizePageLimit, pageFromRows, type PageOptions } from "../pagination.js"; +import { ensureDefaultGroupMembership } from "./groups.js"; +import { ensureProtectedAdminFriendship } from "./relationships.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { defaultInterestNames, defaultInterests, defaultStatus, type PersonCard, type UserProfile } from "../../models.js"; +import { profileVisibilitySql } from "./profileVisibility.js"; +import { containsLikePattern, likeEscapeClause } from "./like.js"; +import { personCardRows } from "./personCards.js"; + +const currentUserColumns = "id, username, email, role, time_zone AS timeZone, verified_at AS verifiedAt, banned_at AS bannedAt"; + +type PagedPersonCardRow = PersonCard & { + createdAt: string; +}; + +export class HandleReservedError extends Error { + constructor(readonly reason: "reserved" | "taken" = "taken") { + super("Profile handle is already reserved."); + } +} + +export type ProfileUpdate = Partial<{ + username: string; + bioHtml: string; + skinHtml: string; + interests: typeof defaultInterests; + socialLinks: SocialLinks; + status: typeof defaultStatus; + pfp: string; + themeSong: string; + private: boolean; +}>; + +export function getUserByEmail(email: string) { + const user = sqlite + .prepare(`SELECT ${currentUserColumns}, password_hash AS passwordHash FROM users WHERE email_canonical = ?`) + .get(canonicalEmail(email)) as (CurrentUser & { passwordHash: string }) | undefined; + return normalizeCurrentUser(user); +} + +export function getCurrentUser(id: number) { + const user = sqlite.prepare(`SELECT ${currentUserColumns} FROM users WHERE id = ?`).get(id) as CurrentUser | undefined; + return normalizeCurrentUser(user); +} + +export function getProfile(id: number) { + const row = sqlite + .prepare( + `SELECT u.id, u.username, u.email, u.role, u.time_zone AS timeZone, u.created_at AS createdAt, + u.verified_at AS verifiedAt, u.banned_at AS bannedAt, p.handle, + p.bio_html AS bioHtml, p.skin_html AS skinHtml, p.interests_json AS interestsJson, + p.social_links_json AS socialLinksJson, p.status_json AS statusJson, p.pfp, p.theme_song AS themeSong, + p.current_group_id AS currentGroupId, p.private, p.views + FROM users u JOIN profiles p ON p.user_id = u.id WHERE u.id = ?` + ) + .get(id) as + | (Omit & { + interestsJson: string; + socialLinksJson: string; + statusJson: string; + private: 0 | 1; + }) + | undefined; + + if (!row) return undefined; + return { + ...row, + timeZone: normalizeTimeZone(row.timeZone), + private: Boolean(row.private), + interests: storedInterests(row.interestsJson), + socialLinks: normalizeStoredSocialLinks(jsonRecord(row.socialLinksJson)), + status: storedStatus(row.statusJson) + } satisfies UserProfile; +} + +export function createUser(input: { username: string; email: string; passwordHash: string; handle: string }) { + const create = sqlite.transaction(() => { + const handle = storedHandle(input.handle); + const email = input.email.trim().toLowerCase(); + if (handleReservationExists(handle)) throw new HandleReservedError("taken"); + const info = sqlite + .prepare("INSERT INTO users (username, email, email_canonical, password_hash) VALUES (?, ?, ?, ?)") + .run(input.username, email, canonicalEmail(email), input.passwordHash); + const userId = Number(info.lastInsertRowid); + if (reservedHandle(handle) && userId !== env.adminUserId) throw new HandleReservedError("reserved"); + sqlite.prepare("INSERT INTO handle_reservations (handle, user_id) VALUES (?, ?)").run(handle, userId); + sqlite + .prepare("INSERT INTO profiles (user_id, interests_json, social_links_json, status_json, pfp, handle) VALUES (?, ?, ?, ?, ?, ?)") + .run(userId, JSON.stringify(defaultInterests), JSON.stringify(defaultSocialLinks), JSON.stringify(defaultStatus), defaultMedia.pfp, handle); + return userId; + }); + try { + const userId = create(); + ensureProtectedAdminFriendship(userId); + ensureDefaultGroupMembership(userId); + return userId; + } catch (error) { + if (isHandleReservationConflict(error)) throw new HandleReservedError(); + throw error; + } +} + +export function updateProfile(userId: number, input: ProfileUpdate) { + const update = sqlite.transaction(() => { + if (input.username) { + sqlite.prepare("UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(input.username, userId); + } + if (input.bioHtml !== undefined) { + sqlite.prepare("UPDATE profiles SET bio_html = ? WHERE user_id = ?").run(input.bioHtml, userId); + } + if (input.skinHtml !== undefined) { + sqlite.prepare("UPDATE profiles SET skin_html = ? WHERE user_id = ?").run(input.skinHtml, userId); + } + if (input.interests) { + sqlite.prepare("UPDATE profiles SET interests_json = ? WHERE user_id = ?").run(JSON.stringify(input.interests), userId); + } + if (input.socialLinks) { + sqlite.prepare("UPDATE profiles SET social_links_json = ? WHERE user_id = ?").run(JSON.stringify(input.socialLinks), userId); + } + if (input.status) { + sqlite.prepare("UPDATE profiles SET status_json = ? WHERE user_id = ?").run(JSON.stringify(input.status), userId); + } + if (input.pfp) sqlite.prepare("UPDATE profiles SET pfp = ? WHERE user_id = ?").run(input.pfp, userId); + if (input.themeSong) sqlite.prepare("UPDATE profiles SET theme_song = ? WHERE user_id = ?").run(input.themeSong, userId); + if (input.private !== undefined) sqlite.prepare("UPDATE profiles SET private = ? WHERE user_id = ?").run(input.private ? 1 : 0, userId); + }); + update(); +} + +export function profileByHandle(handle: string) { + const normalized = handle.trim().toLowerCase(); + if (!validHandle(normalized)) return undefined; + const row = sqlite.prepare("SELECT user_id AS id FROM profiles WHERE handle = ?").get(normalized) as { id: number } | undefined; + return row ? getProfile(row.id) : undefined; +} + +export function profileByProfileImage(filename: string) { + return profileByMediaColumn("pfp", filename); +} + +export function profileByThemeSong(filename: string) { + return profileByMediaColumn("theme_song", filename); +} + +export function handleReserved(handle: string) { + const normalized = storedHandle(handle); + return reservedHandle(normalized) || handleReservationExists(normalized); +} + +export function updateEmail(userId: number, email: string) { + const stored = email.trim().toLowerCase(); + sqlite + .prepare("UPDATE users SET email = ?, email_canonical = ?, verified_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(stored, canonicalEmail(stored), userId); +} + +export function updatePassword(userId: number, passwordHash: string) { + sqlite.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(passwordHash, userId); +} + +export function updateTimeZone(userId: number, timeZone: string) { + sqlite.prepare("UPDATE users SET time_zone = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(normalizeTimeZone(timeZone), userId); +} + +export function setUserRole(userId: number, role: UserRole) { + const normalized = normalizeRole(role); + sqlite.prepare("UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(normalized, userId); +} + +export function setUserBanned(userId: number, banned: boolean) { + sqlite.prepare("UPDATE users SET banned_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(banned ? 1 : 0, userId); +} + +export function markUserVerified(userId: number) { + sqlite.prepare("UPDATE users SET verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND verified_at IS NULL").run(userId); +} + +export function incrementViews(profileId: number, viewerId?: number) { + if (viewerId === profileId) return; + sqlite.prepare("UPDATE profiles SET views = views + 1 WHERE user_id = ?").run(profileId); +} + +export function newestUsers(viewer: CurrentUser | null = null, limit = limits.newestPeople) { + const visible = profileVisibilitySql(viewer); + return personCardRows( + `FROM users u + JOIN profiles p ON p.user_id = u.id + WHERE ${visible.sql} + ORDER BY u.created_at DESC LIMIT ?`, + ...visible.params, + limit + ); +} + +export function searchUsers(query: string, viewer: CurrentUser | null = null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + const pattern = containsLikePattern(query); + return personCardRows( + `FROM users u + JOIN profiles p ON p.user_id = u.id + WHERE u.username LIKE ? ${likeEscapeClause} AND ${visible.sql} + ORDER BY u.username ASC LIMIT ?`, + pattern, + ...visible.params, + limit + ); +} + +export function listUsers(viewer: CurrentUser | null = null, limit = limits.listPage) { + const visible = profileVisibilitySql(viewer); + return personCardRows( + `FROM users u JOIN profiles p ON p.user_id = u.id + WHERE ${visible.sql} ORDER BY u.created_at DESC, u.id DESC LIMIT ?`, + ...visible.params, + limit + ); +} + +export function listUsersPage(viewer: CurrentUser | null = null, options: PageOptions = {}) { + const limit = normalizePageLimit(options.limit, limits.listPage, limits.listPage); + const visible = profileVisibilitySql(viewer); + const before = keysetBeforeCondition(decodeKeysetCursor(options.before), "u.created_at", "u.id"); + const rows = sqlite + .prepare( + `SELECT u.id, u.username, p.handle, p.pfp, u.created_at AS createdAt + FROM users u JOIN profiles p ON p.user_id = u.id + WHERE ${visible.sql} + ${before.sql} + ORDER BY u.created_at DESC, u.id DESC LIMIT ?` + ) + .all(...visible.params, ...before.params, limit + 1) as PagedPersonCardRow[]; + return pageFromRows(rows, limit); +} + +export function deleteAccount(userId: number) { + sqlite.prepare("DELETE FROM users WHERE id = ?").run(userId); +} + +function storedHandle(handle: string) { + const normalized = handle.trim().toLowerCase(); + if (!validHandle(normalized)) throw new Error("Invalid profile handle."); + return normalized; +} + +function handleReservationExists(handle: string) { + return Boolean(sqlite.prepare("SELECT 1 FROM handle_reservations WHERE handle = ?").get(handle)); +} + +function profileByMediaColumn(column: "pfp" | "theme_song", filename: string) { + const row = sqlite.prepare(`SELECT user_id AS id FROM profiles WHERE ${column} = ? LIMIT 1`).get(filename) as { id: number } | undefined; + return row ? getProfile(row.id) : undefined; +} + +function isHandleReservationConflict(error: unknown) { + return error instanceof Error && error.message.includes("handle_reservations.handle"); +} + +function jsonRecord(value: string) { + try { + return recordFromUnknown(JSON.parse(value)); + } catch { + return {}; + } +} + +function storedInterests(value: string) { + const record = jsonRecord(value); + const interests = { ...defaultInterests }; + for (const name of defaultInterestNames) { + interests[name] = stringFromUnknown(record[name]); + } + return interests; +} + +function storedStatus(value: string) { + const record = jsonRecord(value); + return { + status: stringFromUnknown(record.status), + currentVibe: stringFromUnknown(record.currentVibe) + }; +} + +function normalizeCurrentUser(user: T | undefined) { + return user ? { ...user, timeZone: normalizeTimeZone(user.timeZone) } : undefined; +} diff --git a/src/server/email/smtp.ts b/src/server/email/smtp.ts new file mode 100644 index 0000000..6b4b472 --- /dev/null +++ b/src/server/email/smtp.ts @@ -0,0 +1,164 @@ +import net from "node:net"; +import tls from "node:tls"; +import { env } from "../env.js"; + +export function smtpConfigured() { + return Boolean(env.smtpHost && env.smtpFrom); +} + +export async function sendSmtpEmail(toEmail: string, subject: string, bodyText: string) { + let client = await SmtpClient.connect(); + const hostname = env.baseHostname; + try { + await client.expect([220]); + await client.command(`EHLO ${hostname}`, [250]); + if (!env.smtpSecure && env.smtpStartTls) { + await client.command("STARTTLS", [220]); + client = await client.upgradeToTls(); + await client.command(`EHLO ${hostname}`, [250]); + } + if (env.smtpUser || env.smtpPassword) { + const auth = Buffer.from(`\0${env.smtpUser}\0${env.smtpPassword}`).toString("base64"); + await client.command(`AUTH PLAIN ${auth}`, [235]); + } + await client.command(`MAIL FROM:${smtpAddress(env.smtpFrom)}`, [250]); + await client.command(`RCPT TO:${smtpAddress(toEmail)}`, [250, 251]); + await client.command("DATA", [354]); + client.write(`${emailMessage(toEmail, subject, bodyText)}\r\n.\r\n`); + await client.expect([250]); + await client.command("QUIT", [221]); + } finally { + client.close(); + } +} + +function emailMessage(toEmail: string, subject: string, bodyText: string) { + return [ + `Date: ${new Date().toUTCString()}`, + `From: ${emailHeader(env.smtpFrom)}`, + `To: ${emailHeader(toEmail)}`, + `Subject: ${emailHeader(subject)}`, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: 8bit", + "", + dotStuff(bodyText) + ].join("\r\n"); +} + +function dotStuff(value: string) { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => (line.startsWith(".") ? `.${line}` : line)).join("\r\n"); +} + +function smtpAddress(value: string) { + return `<${value.replace(/[<>\r\n]/g, "").trim()}>`; +} + +function emailHeader(value: string) { + return value.replace(/[\r\n]+/g, " ").trim(); +} + +class SmtpClient { + private buffer = ""; + private waiters: Array<() => void> = []; + + private constructor(private socket: net.Socket | tls.TLSSocket) { + this.socket.setEncoding("utf8"); + this.socket.setTimeout(15000); + this.socket.on("data", (chunk) => { + this.buffer += chunk; + this.flushWaiters(); + }); + this.socket.on("error", () => this.flushWaiters()); + this.socket.on("end", () => this.flushWaiters()); + this.socket.on("timeout", () => { + this.socket.destroy(new Error("SMTP connection timed out.")); + this.flushWaiters(); + }); + } + + static connect() { + return new Promise((resolve, reject) => { + const socket = env.smtpSecure + ? tls.connect({ host: env.smtpHost, port: env.smtpPort, servername: env.smtpHost }) + : net.connect({ host: env.smtpHost, port: env.smtpPort }); + const connectEvent = env.smtpSecure ? "secureConnect" : "connect"; + const onError = (error: Error) => { + socket.off(connectEvent, onConnect); + reject(error); + }; + const onConnect = () => { + socket.off("error", onError); + resolve(new SmtpClient(socket)); + }; + socket.once(connectEvent, onConnect); + socket.once("error", onError); + }); + } + + async upgradeToTls() { + this.socket.removeAllListeners("data"); + this.socket.removeAllListeners("error"); + this.socket.removeAllListeners("end"); + this.socket.removeAllListeners("timeout"); + const tlsSocket = tls.connect({ socket: this.socket, servername: env.smtpHost }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + tlsSocket.off("secureConnect", onConnect); + reject(error); + }; + const onConnect = () => { + tlsSocket.off("error", onError); + resolve(); + }; + tlsSocket.once("secureConnect", onConnect); + tlsSocket.once("error", onError); + }); + return new SmtpClient(tlsSocket); + } + + write(value: string) { + this.socket.write(value); + } + + async command(value: string, expected: number[]) { + this.write(`${value}\r\n`); + await this.expect(expected); + } + + async expect(expected: number[]) { + const response = await this.readResponse(); + if (!expected.includes(response.code)) { + throw new Error(`SMTP ${response.code}: ${response.text}`); + } + } + + close() { + this.socket.end(); + } + + private async readResponse() { + while (true) { + const parsed = parseSmtpResponse(this.buffer); + if (parsed) { + this.buffer = this.buffer.slice(parsed.consumed); + return parsed; + } + if (this.socket.destroyed) throw new Error("SMTP connection closed."); + await new Promise((resolve) => this.waiters.push(resolve)); + } + } + + private flushWaiters() { + const waiters = this.waiters.splice(0); + for (const waiter of waiters) waiter(); + } +} + +function parseSmtpResponse(buffer: string) { + const match = buffer.match(/(?:^|\r?\n)(\d{3}) [^\r\n]*(?:\r?\n|$)/); + if (!match || match.index === undefined) return undefined; + const end = match.index + match[0].length; + const text = buffer.slice(0, end).trim(); + return { code: Number(match[1]), text, consumed: end }; +} diff --git a/src/server/env.ts b/src/server/env.ts new file mode 100644 index 0000000..6a6a76a --- /dev/null +++ b/src/server/env.ts @@ -0,0 +1,60 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +function integerEnv(name: string, fallback: number, min = 0, max = Number.MAX_SAFE_INTEGER) { + const raw = process.env[name]?.trim(); + const value = Number(raw || fallback); + return Number.isSafeInteger(value) && value >= min && value <= max ? value : fallback; +} + +function nonEmptyEnv(name: string, fallback: string) { + const value = process.env[name]?.trim(); + return value || fallback; +} + +function urlEnv(name: string, fallback: string) { + try { + const url = new URL(process.env[name]?.trim() || fallback); + return ["http:", "https:"].includes(url.protocol) ? url.href.replace(/\/$/, "") : fallback; + } catch { + return fallback; + } +} + +const baseUrl = urlEnv("BLIISH_BASE_URL", "http://localhost:3000"); +const parsedBaseUrl = new URL(baseUrl); + +export const env = { + baseUrl, + baseOrigin: parsedBaseUrl.origin, + baseHostname: parsedBaseUrl.hostname || "localhost", + secureCookies: parsedBaseUrl.protocol === "https:", + databasePath: nonEmptyEnv("BLIISH_DATABASE_PATH", "./data/bliish.sqlite"), + uploadDir: nonEmptyEnv("BLIISH_UPLOAD_DIR", "./data/uploads"), + adminUserId: integerEnv("BLIISH_ADMIN_USER_ID", 1, 1), + smtpHost: process.env.BLIISH_SMTP_HOST ?? "", + smtpPort: integerEnv("BLIISH_SMTP_PORT", 587, 1, 65535), + smtpUser: process.env.BLIISH_SMTP_USER ?? "", + smtpPassword: process.env.BLIISH_SMTP_PASSWORD ?? "", + smtpFrom: process.env.BLIISH_SMTP_FROM ?? "", + smtpSecure: process.env.BLIISH_SMTP_SECURE === "1", + smtpStartTls: process.env.BLIISH_SMTP_STARTTLS !== "0", + mediaConcurrency: integerEnv("BLIISH_MEDIA_CONCURRENCY", 1, 1, 8), + port: integerEnv("PORT", 3000, 0, 65535), + host: nonEmptyEnv("HOST", "0.0.0.0") +}; + +export const paths = { + db: resolve(env.databasePath), + uploads: resolve(env.uploadDir), + pfp: resolve(env.uploadDir, "pfp"), + postImages: resolve(env.uploadDir, "post-images"), + themeSongs: resolve(env.uploadDir, "theme-songs") +}; + +export function ensureRuntimeDirs() { + mkdirSync(paths.uploads, { recursive: true }); + mkdirSync(paths.pfp, { recursive: true }); + mkdirSync(paths.postImages, { recursive: true }); + mkdirSync(paths.themeSongs, { recursive: true }); +} diff --git a/src/server/forms.ts b/src/server/forms.ts new file mode 100644 index 0000000..f465560 --- /dev/null +++ b/src/server/forms.ts @@ -0,0 +1,15 @@ +import type { AppContext } from "./context.js"; + +export async function readForm(c: AppContext) { + return c.req.parseBody({ all: false }); +} + +export function field(form: Record, name: string, fallback = "") { + const value = form[name]; + return typeof value === "string" ? value.trim() : fallback; +} + +export function fileField(form: Record, name: string) { + const value = form[name]; + return value instanceof File ? value : undefined; +} diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..54f9a72 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,142 @@ +import { HTTPException } from "hono/http-exception"; +import { assertCsrf } from "./auth/session.js"; +import { env } from "./env.js"; +import { field, readForm } from "./forms.js"; +import { isReportSubjectType, type RateLimitAction } from "../policy.js"; +import { assertActionRateLimit } from "./rateLimit.js"; +import { plainTextFromHtml, sanitizeBlogBody, sanitizeUserText } from "./security/html.js"; +import type { AppContext } from "./context.js"; + +type LocalBackOptions = { + avoid?: readonly string[]; + fragment?: string | null; +}; + +export function routeId(c: AppContext, name = "id") { + const id = Number(c.req.param(name)); + if (!Number.isSafeInteger(id) || id < 1) throw new HTTPException(404, { message: "Not found." }); + return id; +} + +export function formId(form: Record, name = "id") { + const id = Number(field(form, name)); + if (!Number.isSafeInteger(id) || id < 1) throw new HTTPException(400, { message: "Invalid id." }); + return id; +} + +export function optionalFormId(form: Record, name = "id") { + return field(form, name) ? formId(form, name) : undefined; +} + +export async function verifiedForm(c: AppContext) { + const form = await readForm(c); + assertCsrf(c, form.csrf); + return form; +} + +export async function verifiedActionForm(c: AppContext, action: RateLimitAction, publicFormSubject?: string | ((form: Record) => string | undefined)) { + const form = await verifiedForm(c); + assertActionRateLimit(c, action, typeof publicFormSubject === "function" ? publicFormSubject(form) : publicFormSubject); + return form; +} + +export function formAction(actions: Record TResult>, name: string, message: string) { + const action = actions[name]; + if (action) return action; + throw new HTTPException(400, { message }); +} + +export function optionalId(value: string | undefined) { + const id = Number(value ?? "0"); + return Number.isSafeInteger(id) && id > 0 ? id : 0; +} + +export function queryText(c: AppContext, names: string | readonly string[], max: number) { + for (const name of Array.isArray(names) ? names : [names]) { + const value = c.req.query(name); + if (value !== undefined) return value.trim().slice(0, max); + } + return ""; +} + +export function requiredField(form: Record, name: string, max: number, message: string) { + const value = field(form, name).slice(0, max); + if (!value) throw new HTTPException(400, { message }); + return value; +} + +export function requiredUserText(form: Record, name: string, max: number, message: string) { + const html = sanitizeUserText(field(form, name).slice(0, max)); + if (!plainTextFromHtml(html)) throw new HTTPException(400, { message }); + return html; +} + +export function requiredBlogBody(form: Record, name: string, max: number, message: string) { + const html = sanitizeBlogBody(field(form, name).slice(0, max)); + if (!plainTextFromHtml(html)) throw new HTTPException(400, { message }); + return html; +} + +export function badFormRequestMessage(error: unknown) { + if (!(error instanceof HTTPException)) return undefined; + if (error.getResponse().status !== 400) return undefined; + return error.message || "The form could not be saved."; +} + +export function optionalReportSubjectType(value: string | undefined) { + return value && isReportSubjectType(value) ? value : undefined; +} + +export function requiredReportSubjectType(value: string) { + if (!isReportSubjectType(value)) throw new HTTPException(400, { message: "Unknown report subject." }); + return value; +} + +export function statusTitle(status: number) { + if (status === 400) return "Bad request"; + if (status === 403) return "Forbidden"; + if (status === 404) return "Not found"; + if (status === 413) return "Payload too large"; + if (status === 429) return "Too many requests"; + return status >= 500 ? "Server error" : "Request error"; +} + +export function localBack(c: AppContext, fallback: string, options: LocalBackOptions = {}) { + const referer = c.req.header("Referer"); + if (!referer) return withFragment(fallback, options.fragment); + if (referer.startsWith("/") && !referer.startsWith("//")) return safeLocalBackTarget(referer, fallback, options); + try { + const url = new URL(referer); + const requestOrigin = new URL(c.req.url).origin; + if (url.origin === env.baseOrigin || url.origin === requestOrigin) return safeLocalBackTarget(`${url.pathname}${url.search}${url.hash}`, fallback, options); + } catch { + return withFragment(fallback, options.fragment); + } + return withFragment(fallback, options.fragment); +} + +export function withFragment(target: string, fragment?: string | null) { + if (!fragment) return target; + const id = fragment.replace(/^#/, "").trim(); + if (!id) return target; + return `${target.replace(/#.*$/, "")}#${encodeURIComponent(id)}`; +} + +function safeLocalBackTarget(target: string, fallback: string, options: LocalBackOptions) { + const destination = avoidedLocalTarget(target, options.avoid) ? fallback : target; + return withFragment(destination, options.fragment); +} + +function avoidedLocalTarget(target: string, avoid: readonly string[] | undefined) { + if (!avoid?.length) return false; + const targetPath = localTargetPath(target); + return avoid.some((path) => localTargetPath(path) === targetPath); +} + +function localTargetPath(target: string) { + try { + return new URL(target, env.baseUrl).pathname; + } catch { + return target.split(/[?#]/, 1)[0]; + } +} diff --git a/src/server/indexing/crawlers.ts b/src/server/indexing/crawlers.ts new file mode 100644 index 0000000..426241e --- /dev/null +++ b/src/server/indexing/crawlers.ts @@ -0,0 +1,181 @@ +import type { MiddlewareHandler } from "hono"; +import { messagesPath, notificationsPath, reportPathBase } from "../../paths.js"; +import type { AppBindings } from "../context.js"; +import { noindexHeader } from "./routes.js"; +import { absoluteUrl, escapeRegExp, normalizePath } from "./urls.js"; + +const privateRobotPaths = [ + "/admin", + "/moderation", + "/settings", + "/account", + messagesPath, + notificationsPath, + "/feed", + "/friends", + "/requests", + "/blocks", + "/favorites", + "/props", + reportPathBase, + "/login", + "/signup", + "/search", + "/logout", + "/reset", + "/verify", + "/refresh", + "/theme", + "/media", + "/account/profile", + "/account/status", + "/blog/new", + "/b/*/edit", + "/g", + "/groups", + "/p", + "/skins/new", + "/s/*/edit", + "/s/*/preview" +] as const; + +const blockedCrawlerAgents = [ + "AI2Bot", + "Ai2Bot-Dolma", + "Amazonbot", + "anthropic-ai", + "Applebot-Extended", + "AwarioRssBot", + "AwarioSmartBot", + "Bytespider", + "CCBot", + "Claude-SearchBot", + "Claude-User", + "ChatGPT-User", + "Claude-Web", + "ClaudeBot", + "cohere-ai", + "Diffbot", + "DuckAssistBot", + "FacebookBot", + "Google-Extended", + "GoogleOther", + "GoogleOther-Image", + "GoogleOther-Video", + "GPTBot", + "ImagesiftBot", + "img2dataset", + "Kangaroo Bot", + "magpie-crawler", + "Meta-ExternalAgent", + "Meta-ExternalFetcher", + "MistralAI-User", + "OAI-SearchBot", + "omgili", + "omgilibot", + "PanguBot", + "peer39_crawler", + "PerplexityBot", + "Perplexity-User", + "QualifiedBot", + "Timpibot", + "YouBot" +] as const; + +const aiCrawlerPattern = new RegExp(blockedCrawlerAgents.map(escapeRegExp).join("|"), "i"); +const crawlerPattern = new RegExp( + [ + "bot", + "crawler", + "spider", + "slurp", + "crawl", + "archiver", + "facebookexternalhit", + "embedly", + "pinterest", + "slackbot", + "telegrambot", + "twitterbot", + "whatsapp", + "AhrefsBot", + "BLEXBot", + "DataForSeoBot", + "DotBot", + "MJ12bot", + "PetalBot", + "SemrushBot", + "SerpstatBot", + "Scrapy", + "axios", + "curl", + "Go-http-client", + "httpclient", + "Java/", + "libwww-perl", + "node-fetch", + "python-requests", + "wget", + "AdsBot-Google", + "Applebot", + "Baiduspider", + "BingPreview", + "Discordbot", + "DuckDuckBot", + "Exabot", + "Facebot", + "Google-InspectionTool", + "Googlebot", + "LinkedInBot", + "MicrosoftPreview", + "Mediapartners-Google", + "Pinterestbot", + "SkypeUriPreview", + "Sogou", + "TeamsBot", + "YandexBot" + ].map(escapeRegExp).join("|"), + "i" +); + +export function blockedCrawlerMiddleware(): MiddlewareHandler { + return async (c, next) => { + const path = normalizePath(new URL(c.req.url).pathname); + if (path !== "/robots.txt" && crawlerKind(c.req.header("user-agent")) === "blocked") { + c.header("X-Robots-Tag", noindexHeader); + c.header("Cache-Control", "private, no-store"); + return c.text("Crawler access is not allowed for this site.", 403); + } + await next(); + }; +} + +export function robotsText() { + return [ + ...blockedCrawlerAgents.flatMap((agent) => [`User-agent: ${agent}`, "Disallow: /", ""]), + "User-agent: *", + ...privateRobotPaths.map((path) => `Disallow: ${path}`), + "Allow: /static/", + "Allow: /theme.css", + "Allow: /branding.css", + "Allow: /favicon.svg", + "Allow: /og-image.png", + "Allow: /og-image.svg", + "Allow: /apple-touch-icon.png", + "Allow: /icon-192.png", + "Allow: /icon-512.png", + "Allow: /icon-1024.png", + "Allow: /site.webmanifest", + "Allow: /", + "", + `Sitemap: ${absoluteUrl("/sitemap.xml")}`, + "" + ].join("\n"); +} + +export function crawlerKind(userAgent: string | undefined) { + if (!userAgent) return "crawler"; + if (aiCrawlerPattern.test(userAgent)) return "blocked"; + if (crawlerPattern.test(userAgent)) return "crawler"; + return "browser"; +} diff --git a/src/server/indexing/middleware.ts b/src/server/indexing/middleware.ts new file mode 100644 index 0000000..bf028fe --- /dev/null +++ b/src/server/indexing/middleware.ts @@ -0,0 +1,28 @@ +import type { MiddlewareHandler } from "hono"; +import type { AppBindings } from "../context.js"; +import { crawlerKind } from "./crawlers.js"; +import { noindexHeader, resolveIndexing } from "./routes.js"; +import { absoluteUrl } from "./urls.js"; + +export function indexingMiddleware(): MiddlewareHandler { + return async (c, next) => { + const decision = resolveIndexing(c); + const crawler = crawlerKind(c.req.header("user-agent")); + + if (crawler === "blocked" || (crawler === "crawler" && !decision.index)) { + c.header("X-Robots-Tag", noindexHeader); + c.header("Cache-Control", "private, no-store"); + return c.text("Crawler access is not allowed for this page.", 403); + } + + try { + await next(); + } finally { + if (decision.index) { + c.header("Link", `<${absoluteUrl(decision.canonicalPath)}>; rel="canonical"`); + } else { + c.header("X-Robots-Tag", noindexHeader); + } + } + }; +} diff --git a/src/server/indexing/policy.test.ts b/src/server/indexing/policy.test.ts new file mode 100644 index 0000000..e48b9b4 --- /dev/null +++ b/src/server/indexing/policy.test.ts @@ -0,0 +1,180 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppBindings } from "../context.js"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIndexingApp() { + const modules = await loadIndexingModules(); + const { indexingMiddleware } = await import("./middleware.js"); + const app = new Hono(); + + app.use(async (c, next) => { + c.set("currentUser", null); + c.set("csrfToken", "token"); + await next(); + }); + app.use(indexingMiddleware()); + app.all("*", (c) => c.text("ok")); + + return { app, ...modules }; +} + +async function loadBlockedCrawlerApp() { + const modules = await loadIndexingModules(); + const { blockedCrawlerMiddleware } = await import("./crawlers.js"); + const app = new Hono(); + app.use(blockedCrawlerMiddleware()); + app.get("/robots.txt", (c) => c.text("robots")); + app.get("/static/css/style.css", (c) => c.text("css")); + app.get("/sitemap.xml", (c) => c.text("sitemap")); + return { app, ...modules }; +} + +async function loadCrawlerRoutesApp() { + const modules = await loadIndexingModules(); + const { registerCrawlerRoutes } = await import("../../routes/system/crawlers.js"); + const app = new Hono(); + registerCrawlerRoutes(app); + return { app, ...modules }; +} + +async function loadIndexingModules() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-indexing-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + process.env.BLIISH_BASE_URL = "https://example.test"; + + const client = await import("../db/client.js"); + db = client.sqlite; + const schema = await import("../db/schema.js"); + schema.initializeDatabase(); + + return { + blogs: await import("../db/blogs/index.js"), + skins: await import("../db/skins.js"), + users: await import("../db/users.js") + }; +} + +function requestPage(app: Hono, path: string, userAgent = "Mozilla/5.0") { + return app.request(path, { headers: { "user-agent": userAgent } }); +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; + delete process.env.BLIISH_BASE_URL; +}); + +describe("indexing policy", () => { + it("indexes public profiles and noindexes private profiles", async () => { + const { app, users } = await loadIndexingApp(); + const publicId = users.createUser({ username: "Public", email: "public@example.test", handle: "public", passwordHash: "hash" }); + const privateId = users.createUser({ username: "Private", email: "private@example.test", handle: "private", passwordHash: "hash" }); + const bannedId = users.createUser({ username: "Banned", email: "banned@example.test", handle: "banned", passwordHash: "hash" }); + users.updateProfile(privateId, { private: true }); + users.setUserBanned(bannedId, true); + + const publicResponse = await requestPage(app, "/u/public"); + const privateResponse = await requestPage(app, "/u/private"); + const bannedResponse = await requestPage(app, "/u/banned"); + + expect(publicResponse.status).toBe(200); + expect(publicResponse.headers.get("X-Robots-Tag")).toBeNull(); + expect(privateResponse.status).toBe(200); + expect(privateResponse.headers.get("X-Robots-Tag")).toContain("noindex"); + expect(bannedResponse.status).toBe(200); + expect(bannedResponse.headers.get("X-Robots-Tag")).toContain("noindex"); + }); + + it("noindexes blogs that are friends-only, private, or under private authors", async () => { + const { app, blogs, users } = await loadIndexingApp(); + const publicAuthorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const privateAuthorId = users.createUser({ username: "PrivateAuthor", email: "private-author@example.test", handle: "private-author", passwordHash: "hash" }); + users.updateProfile(privateAuthorId, { private: true }); + const publicBlogId = blogs.createBlog(publicAuthorId, "Public", "body", "Life", 0); + const friendsBlogId = blogs.createBlog(publicAuthorId, "Friends", "body", "Life", 1); + const privateBlogId = blogs.createBlog(publicAuthorId, "Private", "body", "Life", 2); + const privateAuthorBlogId = blogs.createBlog(privateAuthorId, "Hidden", "body", "Life", 0); + + expect((await requestPage(app, `/b/${publicBlogId}`)).headers.get("X-Robots-Tag")).toBeNull(); + expect((await requestPage(app, `/b/${friendsBlogId}`)).headers.get("X-Robots-Tag")).toContain("noindex"); + expect((await requestPage(app, `/b/${privateBlogId}`)).headers.get("X-Robots-Tag")).toContain("noindex"); + expect((await requestPage(app, `/b/${privateAuthorBlogId}`)).headers.get("X-Robots-Tag")).toContain("noindex"); + }); + + it("blocks crawlers from non-indexable pages while allowing search bots on public pages", async () => { + const { app, users } = await loadIndexingApp(); + const publicId = users.createUser({ username: "Public", email: "public@example.test", handle: "public", passwordHash: "hash" }); + const privateId = users.createUser({ username: "Private", email: "private@example.test", handle: "private", passwordHash: "hash" }); + users.updateProfile(privateId, { private: true }); + + const publicSearchBot = await requestPage(app, "/u/public", "Googlebot/2.1"); + const privateSearchBot = await requestPage(app, "/u/private", "Googlebot/2.1"); + const publicCommandLineClient = await requestPage(app, "/u/public", "curl/8.0"); + const privateCommandLineClient = await requestPage(app, "/u/private", "curl/8.0"); + const blockedAiCrawler = await requestPage(app, "/u/public", "GPTBot/1.0"); + + expect(publicSearchBot.status).toBe(200); + expect(publicSearchBot.headers.get("X-Robots-Tag")).toBeNull(); + expect(privateSearchBot.status).toBe(403); + expect(privateSearchBot.headers.get("X-Robots-Tag")).toContain("noindex"); + expect(publicCommandLineClient.status).toBe(200); + expect(privateCommandLineClient.status).toBe(403); + expect(blockedAiCrawler.status).toBe(403); + }); + + it("serves robots policy for private areas and AI crawler agents", async () => { + const { app } = await loadCrawlerRoutesApp(); + + const text = await (await app.request("/robots.txt")).text(); + + expect(text).toContain("User-agent: GPTBot\nDisallow: /"); + expect(text).toContain("User-agent: *\nDisallow: /admin"); + expect(text).toContain("Sitemap: https://example.test/sitemap.xml"); + }); + + it("blocks AI crawler agents before assets while keeping robots readable", async () => { + const { app } = await loadBlockedCrawlerApp(); + + const robots = await requestPage(app, "/robots.txt", "GPTBot/1.0"); + const staticAsset = await requestPage(app, "/static/css/style.css", "GPTBot/1.0"); + const sitemap = await requestPage(app, "/sitemap.xml", "GPTBot/1.0"); + + expect(robots.status).toBe(200); + expect(staticAsset.status).toBe(403); + expect(staticAsset.headers.get("X-Robots-Tag")).toContain("noindex"); + expect(sitemap.status).toBe(403); + }); + + it("builds a sitemap from only public canonical content", async () => { + const { app, blogs, skins, users } = await loadCrawlerRoutesApp(); + const publicAuthorId = users.createUser({ username: "Public", email: "public@example.test", handle: "public", passwordHash: "hash" }); + const privateAuthorId = users.createUser({ username: "Private", email: "private@example.test", handle: "private", passwordHash: "hash" }); + users.updateProfile(privateAuthorId, { private: true }); + const publicBlogId = blogs.createBlog(publicAuthorId, "Public", "body", "Life", 0); + const friendsBlogId = blogs.createBlog(publicAuthorId, "Friends", "body", "Life", 1); + const privateAuthorBlogId = blogs.createBlog(privateAuthorId, "Hidden", "body", "Life", 0); + const skinId = skins.createSkin(publicAuthorId, "Skin", "description", "
skin
"); + + const xml = await (await app.request("/sitemap.xml")).text(); + + expect(xml).toContain("/u/public"); + expect(xml).toContain(`/b/${publicBlogId}`); + expect(xml).toContain(`/s/${skinId}`); + expect(xml).not.toContain("/u/private"); + expect(xml).not.toContain(`/b/${friendsBlogId}`); + expect(xml).not.toContain(`/b/${privateAuthorBlogId}`); + }); +}); diff --git a/src/server/indexing/routes.ts b/src/server/indexing/routes.ts new file mode 100644 index 0000000..5fc4156 --- /dev/null +++ b/src/server/indexing/routes.ts @@ -0,0 +1,96 @@ +import { isBlogCategory } from "../../policy.js"; +import { + publicBlogCanonicalPath, + publicProfileCanonicalPathByHandle, + publicSkinCanonicalPath +} from "../db/indexing.js"; +import type { AppContext } from "../context.js"; +import { decodeSegment, idRoute, normalizePath, routeSuffix } from "./urls.js"; + +type IndexingDecision = + | { index: true; canonicalPath: string } + | { index: false; reason: string }; + +export const noindexHeader = "noindex, nofollow, noarchive, noimageindex"; + +export const staticContentPaths = [ + "/", + "/about", + "/source", + "/license", + "/credits", + "/contact", + "/privacy", + "/terms", + "/help", + "/rules", + "/browse", + "/blog", + "/skins" +] as const; + +const staticContentPathSet = new Set(staticContentPaths); + +export function resolveIndexing(c: AppContext): IndexingDecision { + if (c.req.method !== "GET" && c.req.method !== "HEAD") return noindex("non-GET request"); + if (c.get("currentUser")) return noindex("authenticated request"); + + const url = new URL(c.req.url); + if (url.search) return noindex("query string"); + + return resolveIndexingPath(normalizePath(url.pathname)); +} + +function resolveIndexingPath(path: string): IndexingDecision { + if (staticContentPathSet.has(path)) return index(path); + + const blogCategory = routeSuffix(path, "/blog/category/"); + if (blogCategory !== null) { + const category = decodeSegment(blogCategory); + return category && isBlogCategory(category) ? index(`/blog/category/${encodeURIComponent(category)}`) : noindex("unknown blog category"); + } + + const profile = profileRoute(path); + if (profile) { + const canonicalPath = publicProfileCanonicalPathByHandle(profile.handle); + if (!canonicalPath) return noindex("non-public profile handle"); + return index(profile.suffix ? `${canonicalPath}${profile.suffix}` : canonicalPath); + } + + const blog = idRoute(path, "/b/"); + if (blog) { + if (!["", "/comments"].includes(blog.suffix)) return noindex("non-canonical blog route"); + const canonicalPath = publicBlogCanonicalPath(blog.id); + if (!canonicalPath) return noindex("non-public blog"); + return index(canonicalPath); + } + + const skin = idRoute(path, "/s/"); + if (skin) { + if (!["", "/comments"].includes(skin.suffix)) return noindex("non-canonical skin route"); + const canonicalPath = publicSkinCanonicalPath(skin.id); + if (!canonicalPath) return noindex("non-public skin"); + return index(canonicalPath); + } + + return noindex("unregistered route"); +} + +function profileRoute(path: string) { + if (!path.startsWith("/u/")) return null; + const rest = path.slice("/u/".length); + const [rawHandle, ...parts] = rest.split("/"); + const handle = decodeSegment(rawHandle)?.toLowerCase(); + if (!handle) return null; + const suffix = parts.length ? `/${parts.join("/")}` : ""; + if (!["", "/blog", "/friends", "/wall"].includes(suffix)) return null; + return { handle, suffix }; +} + +function index(canonicalPath: string): IndexingDecision { + return { index: true, canonicalPath }; +} + +function noindex(reason: string): IndexingDecision { + return { index: false, reason }; +} diff --git a/src/server/indexing/sitemap.ts b/src/server/indexing/sitemap.ts new file mode 100644 index 0000000..8c4bf0e --- /dev/null +++ b/src/server/indexing/sitemap.ts @@ -0,0 +1,66 @@ +import { + publicBlogCategoryIndexPaths, + publicBlogIndexPaths, + publicProfileIndexPaths, + publicSkinIndexPaths +} from "../db/indexing.js"; +import { siteSettings } from "../db/siteSettings.js"; +import { staticContentPaths } from "./routes.js"; +import { absoluteUrl, xmlEscape } from "./urls.js"; + +const sitemapUrlLimit = 50_000; +type SitemapPath = { path: string; lastmod?: string | null }; + +export function sitemapXml() { + const paths = sitemapPaths(); + return [ + '', + '', + ...paths.map((path) => sitemapUrlXml(path)), + "", + "" + ].join("\n"); +} + +function sitemapPaths() { + const paths: SitemapPath[] = []; + const seen = new Set(); + addSitemapPaths(paths, seen, staticSitemapPaths()); + addSitemapPaths(paths, seen, publicBlogCategoryIndexPaths(remainingSitemapSlots(paths))); + addSitemapPaths(paths, seen, publicProfileIndexPaths(remainingSitemapSlots(paths))); + addSitemapPaths(paths, seen, publicBlogIndexPaths(remainingSitemapSlots(paths))); + addSitemapPaths(paths, seen, publicSkinIndexPaths(remainingSitemapSlots(paths))); + return paths; +} + +function staticSitemapPaths(): SitemapPath[] { + const lastmod = siteSettings().updatedAt; + return staticContentPaths.map((path) => ({ path, lastmod })); +} + +function addSitemapPaths(paths: SitemapPath[], seen: Set, nextPaths: readonly SitemapPath[]) { + for (const row of nextPaths) { + if (paths.length >= sitemapUrlLimit) return; + if (seen.has(row.path)) continue; + seen.add(row.path); + paths.push(row); + } +} + +function remainingSitemapSlots(paths: readonly SitemapPath[]) { + return Math.max(sitemapUrlLimit - paths.length, 0); +} + +function sitemapUrlXml(row: SitemapPath) { + const lastmod = sitemapLastmod(row.lastmod); + return lastmod + ? ` ${xmlEscape(absoluteUrl(row.path))}${xmlEscape(lastmod)}` + : ` ${xmlEscape(absoluteUrl(row.path))}`; +} + +function sitemapLastmod(value: string | null | undefined) { + if (!value) return null; + const candidate = value.includes("T") ? value : `${value.replace(" ", "T")}Z`; + const date = new Date(candidate); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} diff --git a/src/server/indexing/urls.ts b/src/server/indexing/urls.ts new file mode 100644 index 0000000..6a2e84b --- /dev/null +++ b/src/server/indexing/urls.ts @@ -0,0 +1,57 @@ +import { env } from "../env.js"; + +export function absoluteUrl(path: string) { + return new URL(path, `${env.baseUrl}/`).href; +} + +export function normalizePath(path: string) { + if (path.length > 1 && path.endsWith("/")) return path.slice(0, -1); + return path; +} + +export function routeSuffix(path: string, prefix: string) { + if (!path.startsWith(prefix)) return null; + const suffix = path.slice(prefix.length); + return suffix && !suffix.includes("/") ? suffix : null; +} + +export function idRoute(path: string, prefix: string) { + if (!path.startsWith(prefix)) return null; + const rest = path.slice(prefix.length); + const [idValue, ...parts] = rest.split("/"); + const id = Number(idValue); + if (!Number.isSafeInteger(id) || id < 1) return null; + const suffix = parts.length ? `/${parts.join("/")}` : ""; + return { id, suffix }; +} + +export function decodeSegment(value: string) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +export function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function xmlEscape(value: string) { + return value.replace(/[<>&'"]/g, (char) => { + switch (char) { + case "<": + return "<"; + case ">": + return ">"; + case "&": + return "&"; + case "'": + return "'"; + case '"': + return """; + default: + return char; + } + }); +} diff --git a/src/server/media/policy.ts b/src/server/media/policy.ts new file mode 100644 index 0000000..31baa0f --- /dev/null +++ b/src/server/media/policy.ts @@ -0,0 +1,87 @@ +import { paths } from "../env.js"; + +type MediaClass = "image" | "audio"; +export type MediaUploadTarget = "profileImage" | "postImage" | "profileThemeSong"; + +type MediaTypePolicy = { + mimeExtensions: ReadonlyMap; + extensions: ReadonlySet; +}; + +type ImageProcessingPolicy = { + maxWidth: number; + maxHeight: number; + limitInputPixels: number; + quality: number; + effort: number; + output: { + contentType: "image/webp"; + ext: ".webp"; + }; +}; + +export type MediaUploadPolicy = { + dir: string; + mediaClass: MediaClass; + image?: ImageProcessingPolicy; +}; + +const imageMimeExtensions = new Map([ + ["image/jpeg", ".jpg"], + ["image/png", ".png"], + ["image/gif", ".gif"], + ["image/webp", ".webp"] +]); + +const audioMimeExtensions = new Map([ + ["audio/mpeg", ".mp3"], + ["audio/mp3", ".mp3"] +]); + +const imageProcessingDefaults = { + effort: 4, + limitInputPixels: 40_000_000, + output: { + contentType: "image/webp", + ext: ".webp" + }, + quality: 82 +} as const; + +export const extensionAliases = new Map([[".jpeg", ".jpg"]]); + +export const mediaTypes = { + image: { + extensions: new Set(imageMimeExtensions.values()), + mimeExtensions: imageMimeExtensions + }, + audio: { + extensions: new Set(audioMimeExtensions.values()), + mimeExtensions: audioMimeExtensions + } +} satisfies Record; + +export const mediaUploadPolicies = { + profileImage: { + dir: paths.pfp, + image: { + ...imageProcessingDefaults, + maxHeight: 768, + maxWidth: 768 + }, + mediaClass: "image" + }, + postImage: { + dir: paths.postImages, + image: { + ...imageProcessingDefaults, + maxHeight: 2048, + maxWidth: 2048 + }, + mediaClass: "image" + }, + profileThemeSong: { + dir: paths.themeSongs, + mediaClass: "audio" + } +} satisfies Record; diff --git a/src/server/media/processing.ts b/src/server/media/processing.ts new file mode 100644 index 0000000..809e05f --- /dev/null +++ b/src/server/media/processing.ts @@ -0,0 +1,68 @@ +import { HTTPException } from "hono/http-exception"; +import sharp from "sharp"; +import { env } from "../env.js"; +import type { MediaUploadPolicy } from "./policy.js"; + +type ProcessedMedia = { + buffer: Buffer; + contentType: string; + ext: string; +}; + +sharp.concurrency(env.mediaConcurrency); + +export async function processUserMedia(input: { + buffer: Buffer; + contentType: string; + ext: string; + policy: MediaUploadPolicy; +}): Promise { + if (input.policy.mediaClass === "image") return processImage(input); + + return { + buffer: input.buffer, + contentType: input.contentType, + ext: input.ext + }; +} + +async function processImage(input: { + buffer: Buffer; + contentType: string; + ext: string; + policy: MediaUploadPolicy; +}): Promise { + const policy = input.policy.image; + if (!policy) throw new HTTPException(500, { message: "Image processing policy is missing." }); + + try { + const buffer = await sharp(input.buffer, { + animated: supportsAnimation(input.ext), + limitInputPixels: policy.limitInputPixels + }) + .rotate() + .resize({ + fit: "inside", + height: policy.maxHeight, + width: policy.maxWidth, + withoutEnlargement: true + }) + .webp({ + effort: policy.effort, + quality: policy.quality + }) + .toBuffer(); + + return { + buffer, + contentType: policy.output.contentType, + ext: policy.output.ext + }; + } catch { + throw new HTTPException(400, { message: "Image could not be processed." }); + } +} + +function supportsAnimation(ext: string) { + return ext === ".gif" || ext === ".webp"; +} diff --git a/src/server/media/upload.ts b/src/server/media/upload.ts new file mode 100644 index 0000000..fa65e79 --- /dev/null +++ b/src/server/media/upload.ts @@ -0,0 +1,62 @@ +import { open, rm } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { HTTPException } from "hono/http-exception"; +import { defaultMedia, defaultProfileImageNames, limits } from "../../policy.js"; +import { mediaUploadPolicies, type MediaUploadTarget } from "./policy.js"; +import { processUserMedia } from "./processing.js"; +import { validateIncomingMedia } from "./validation.js"; + +export async function saveProfileImage(file: File) { + return saveUpload(file, "profileImage"); +} + +export async function savePostImage(file: File) { + return saveUpload(file, "postImage"); +} + +export async function saveProfileThemeSong(file: File) { + return saveUpload(file, "profileThemeSong"); +} + +export async function deleteProfileImage(filename: string) { + if (defaultProfileImageNames.has(filename)) return; + await deleteUpload(mediaUploadPolicies.profileImage.dir, filename, defaultMedia.pfp); +} + +export async function deletePostImage(filename: string | null | undefined) { + await deleteUpload(mediaUploadPolicies.postImage.dir, filename ?? "", ""); +} + +export async function deletePostImages(filenames: Iterable) { + await Promise.all([...filenames].map(deletePostImage)); +} + +export async function deleteProfileThemeSong(filename: string) { + await deleteUpload(mediaUploadPolicies.profileThemeSong.dir, filename, defaultMedia.themeSong); +} + +async function saveUpload(file: File, target: MediaUploadTarget) { + if (!file || file.size === 0) return undefined; + if (file.size > limits.uploadBytes) throw new HTTPException(400, { message: "File is too large." }); + + const buffer = Buffer.from(await file.arrayBuffer()); + const policy = mediaUploadPolicies[target]; + const accepted = validateIncomingMedia(file, buffer, policy); + const processed = await processUserMedia({ ...accepted, buffer, policy }); + if (processed.buffer.length > limits.uploadBytes) throw new HTTPException(400, { message: "File is too large." }); + + const filename = `${randomUUID()}${processed.ext}`; + const handle = await open(join(policy.dir, filename), "wx"); + try { + await handle.writeFile(processed.buffer); + } finally { + await handle.close(); + } + return filename; +} + +async function deleteUpload(dir: string, filename: string, fallback: string) { + if (!filename || filename === fallback || filename !== basename(filename)) return; + await rm(join(dir, filename), { force: true }); +} diff --git a/src/server/media/validation.ts b/src/server/media/validation.ts new file mode 100644 index 0000000..c881b7f --- /dev/null +++ b/src/server/media/validation.ts @@ -0,0 +1,42 @@ +import { extname } from "node:path"; +import { HTTPException } from "hono/http-exception"; +import { extensionAliases, mediaTypes, type MediaUploadPolicy } from "./policy.js"; + +type AcceptedMedia = { + contentType: string; + ext: string; +}; + +const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const fileSignatures = new Map boolean>([ + [".jpg", (buffer) => buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff], + [".png", (buffer) => buffer.subarray(0, pngSignature.length).equals(pngSignature)], + [".gif", (buffer) => buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a"], + [".webp", (buffer) => buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP"], + [".mp3", (buffer) => buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)] +]); + +export function validateIncomingMedia(file: File, buffer: Buffer, policy: MediaUploadPolicy): AcceptedMedia { + const mediaType = mediaTypes[policy.mediaClass]; + const ext = acceptedExtension(file, mediaType); + if (!ext || !fileSignatures.get(ext)?.(buffer)) throw new HTTPException(400, { message: "File type is not allowed." }); + + return { + contentType: contentTypeForExtension(ext, mediaType) ?? file.type, + ext + }; +} + +function acceptedExtension(file: File, mediaType: (typeof mediaTypes)[keyof typeof mediaTypes]) { + const fileExt = extname(file.name).toLowerCase(); + const fallbackExt = extensionAliases.get(fileExt) ?? fileExt; + return mediaType.mimeExtensions.get(file.type) ?? (mediaType.extensions.has(fallbackExt) ? fallbackExt : undefined); +} + +function contentTypeForExtension(ext: string, mediaType: (typeof mediaTypes)[keyof typeof mediaTypes]) { + for (const [contentType, contentExt] of mediaType.mimeExtensions) { + if (contentExt === ext) return contentType; + } + return undefined; +} diff --git a/src/server/moderation/actions.test.ts b/src/server/moderation/actions.test.ts new file mode 100644 index 0000000..631adae --- /dev/null +++ b/src/server/moderation/actions.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedDb() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-moderation-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("../db/client.js"); + db = client.sqlite; + const schema = await import("../db/schema.js"); + schema.initializeDatabase(); + + return { + actions: await import("./actions.js"), + blogs: await import("../db/blogs/index.js"), + moderation: await import("../db/moderation/index.js"), + users: await import("../db/users.js") + }; +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("moderation report actions", () => { + it("does not let non-staff resolve reports through the action helper", async () => { + const { actions, blogs, moderation, users } = await loadIsolatedDb(); + const userId = users.createUser({ username: "User", email: "user@example.test", handle: "user", passwordHash: "hash" }); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + const blogId = blogs.createBlog(authorId, "Reported", "body"); + moderation.createReport(userId, "blog", blogId, "reason", authorId); + + const user = users.getCurrentUser(userId); + if (!user) throw new Error("missing user"); + await expect(actions.moderateReport(user, 1, "resolve")).rejects.toThrow(); + expect(moderation.getReport(1)?.resolvedAt).toBeNull(); + }); + + it("lets moderators delete reported regular-user content and resolve the report", async () => { + const { actions, blogs, moderation, users } = await loadIsolatedDb(); + const modId = users.createUser({ username: "Mod", email: "mod@example.test", handle: "mod", passwordHash: "hash" }); + const authorId = users.createUser({ username: "Author", email: "author@example.test", handle: "author", passwordHash: "hash" }); + users.setUserRole(modId, "moderator"); + const blogId = blogs.createBlog(authorId, "Reported", "body"); + moderation.createReport(modId, "blog", blogId, "reason", authorId); + + const mod = users.getCurrentUser(modId); + if (!mod) throw new Error("missing moderator"); + await actions.moderateReport(mod, 1, "delete"); + + expect(blogs.getBlog(blogId)).toBeUndefined(); + expect(moderation.getReport(1)?.resolvedAt).toBeTruthy(); + }); + + it("does not let moderators delete staff-authored content", async () => { + const { actions, blogs, moderation, users } = await loadIsolatedDb(); + const modId = users.createUser({ username: "Mod", email: "mod@example.test", handle: "mod", passwordHash: "hash" }); + const adminId = users.createUser({ username: "Admin", email: "admin@example.test", handle: "admin-user", passwordHash: "hash" }); + users.setUserRole(modId, "moderator"); + users.setUserRole(adminId, "admin"); + const blogId = blogs.createBlog(adminId, "Reported", "body"); + moderation.createReport(modId, "blog", blogId, "reason", adminId); + + const mod = users.getCurrentUser(modId); + if (!mod) throw new Error("missing moderator"); + await expect(actions.moderateReport(mod, 1, "delete")).rejects.toThrow(); + expect(blogs.getBlog(blogId)).toBeTruthy(); + expect(moderation.getReport(1)?.resolvedAt).toBeNull(); + }); +}); diff --git a/src/server/moderation/actions.ts b/src/server/moderation/actions.ts new file mode 100644 index 0000000..dc66930 --- /dev/null +++ b/src/server/moderation/actions.ts @@ -0,0 +1,88 @@ +import { HTTPException } from "hono/http-exception"; +import { assertMutableManagedUser } from "../adminProtection.js"; +import { revokeUserSessions } from "../auth/session.js"; +import { audit, auditSubjectMetadata, getReport, resolveReport } from "../db/moderation/index.js"; +import { deleteModerationSubject, getModerationSubject } from "../db/moderation/subjects.js"; +import { getCurrentUser, setUserBanned } from "../db/users.js"; +import { canBanUsers, canModerateContent, canModerateTarget, canViewReports } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import type { ReportItem } from "../../models.js"; +import type { ReportSubjectType } from "../../policy.js"; + +export type ReportAction = "resolve" | "delete" | "ban_author" | "delete_and_ban"; +type ReportActionInput = { actor: CurrentUser; report: ReportItem; reasonHtml: string }; + +const reportActionHandlers = { + resolve: ({ actor, report, reasonHtml }: ReportActionInput) => { + resolveReport(report.id, actor.id); + audit(actor.id, "resolve", "report", report.id, reasonHtml); + }, + delete: async ({ actor, report, reasonHtml }: ReportActionInput) => { + await deleteReportedSubject(actor, report.subjectType, report.subjectId, report.id, reasonHtml); + resolveReport(report.id, actor.id); + }, + ban_author: ({ actor, report, reasonHtml }: ReportActionInput) => { + banReportedAuthor(actor, reportAuthorId(report), report.id, reasonHtml); + resolveReport(report.id, actor.id); + }, + delete_and_ban: async ({ actor, report, reasonHtml }: ReportActionInput) => { + const authorId = reportAuthorId(report); + await deleteReportedSubject(actor, report.subjectType, report.subjectId, report.id, reasonHtml); + banReportedAuthor(actor, authorId, report.id, reasonHtml); + resolveReport(report.id, actor.id); + } +} satisfies Record void | Promise>; + +export async function moderateReport(actor: CurrentUser, reportId: number, action: ReportAction, reasonHtml = "") { + if (!canViewReports(actor)) throw new HTTPException(403, { message: "Moderation access required." }); + const report = getReport(reportId); + if (!report) throw new HTTPException(404, { message: "Report not found." }); + if (report.resolvedAt) throw new HTTPException(400, { message: "Report is already resolved." }); + await reportActionHandlers[action]({ actor, report, reasonHtml }); +} + +export function moderateUserBan( + actor: CurrentUser, + targetId: number, + banned: boolean, + reasonHtml = "", + metadata: Record = {} +) { + if (!canBanUsers(actor)) throw new HTTPException(403, { message: "Ban access required." }); + const target = getCurrentUser(targetId); + if (!target) throw new HTTPException(404, { message: "User not found." }); + assertMutableManagedUser(target); + if (!canModerateTarget(actor, target)) throw new HTTPException(403, { message: "You cannot moderate this user." }); + setUserBanned(target.id, banned); + if (banned) revokeUserSessions(target.id); + audit(actor.id, banned ? "ban" : "unban", "user", target.id, reasonHtml, metadata); + return target; +} + +function reportAuthorId(report: ReportItem) { + return report.subjectAuthorId ?? getModerationSubject(report.subjectType, report.subjectId)?.authorId ?? null; +} + +async function deleteReportedSubject(actor: CurrentUser, subjectType: ReportSubjectType, subjectId: number, reportId: number, reasonHtml: string) { + if (!canModerateContent(actor)) throw new HTTPException(403, { message: "Moderation access required." }); + const subject = getModerationSubject(subjectType, subjectId); + if (!subject) throw new HTTPException(404, { message: "Report subject not found." }); + if (!subject.canDelete) throw new HTTPException(400, { message: "This report subject cannot be deleted." }); + assertCanModerateAuthor(actor, subject.authorId); + if (!(await deleteModerationSubject(subjectType, subjectId, actor.id))) { + throw new HTTPException(404, { message: "Report subject was already removed." }); + } + audit(actor.id, "delete", subjectType, subjectId, reasonHtml, { reportId, ...auditSubjectMetadata(subject) }); +} + +function banReportedAuthor(actor: CurrentUser, subjectAuthorId: number | null, reportId: number, reasonHtml: string) { + if (!subjectAuthorId) throw new HTTPException(400, { message: "Report subject author is unknown." }); + moderateUserBan(actor, subjectAuthorId, true, reasonHtml, { reportId }); +} + +function assertCanModerateAuthor(actor: CurrentUser, authorId: number | null) { + if (!authorId) throw new HTTPException(400, { message: "Report subject author is unknown." }); + const target = getCurrentUser(authorId); + if (!target) throw new HTTPException(404, { message: "Report subject author no longer exists." }); + if (!canModerateTarget(actor, target)) throw new HTTPException(403, { message: "You cannot moderate this user's content." }); +} diff --git a/src/server/moderation/automodDefaults.ts b/src/server/moderation/automodDefaults.ts new file mode 100644 index 0000000..477fe1c --- /dev/null +++ b/src/server/moderation/automodDefaults.ts @@ -0,0 +1,193 @@ +import { sqlite } from "../db/client.js"; +import { saveSetting, settingExists } from "../db/settings.js"; +import type { AutomodAction, AutomodPatternType, AutomodScope } from "../../automodPolicy.js"; + +type DefaultAutomodRule = { + name: string; + pattern: string; + patternType: AutomodPatternType; + scope: AutomodScope; + action: AutomodAction; +}; + +const defaultAutomodRulesInstallKey = "automod.default_rules.installed"; + +// Keep these source lists split by moderation intent so contributors can review +// high-confidence rejects separately from review-only phrases. +const hateSlurs: string[] = [ + "nigger", + "nigga", + "coon", + "jigaboo", + "porch monkey", + "jungle bunny", + "spear chucker", + "tar baby", + "kike", + "hebe", + "sheeny", + "oven dodger", + "spic", + "spick", + "beaner", + "wetback", + "tacohead", + "chink", + "gook", + "zipperhead", + "paki", + "curry muncher", + "raghead", + "towelhead", + "camel jockey", + "prairie nigger", + "wagon burner", + "gyppo", + "faggot", + "tranny", + "shemale", + "troon" +]; + +const reviewOnlySlurs: string[] = [ + "retard", + "mongoloid", + "retarded", + "spaz", + "cripple", + "jap", + "nip", + "redskin", + "gypsy", + "dyke", + "fag", + "hermaphrodite", + "trap", + "groomer" +]; + +const hateAbusePhrases: string[] = [ + "white power", + "blood and soil", + "1488", + "heil hitler", + "jews will not replace us", + "gas the jews", + "globalist cabal", + "jewish cabal", + "zionist occupied government", + "white genocide", + "great replacement", + "race war now", + "go back to your country", + "race traitor", + "mud people", + "subhuman race", + "inferior race", + "deport all muslims", + "deport all jews", + "deport all black people", + "kill all jews", + "kill all black people", + "kill all muslims", + "kill all gays", + "kill all trans people", + "gas all jews", + "burn down synagogues", + "bomb a mosque", + "shoot up a synagogue", + "shoot up a mosque" +]; + +const harassmentThreatPhrases: string[] = [ + "i will kill you", + "i am going to kill you", + "i'm going to kill you", + "im going to kill you", + "i'm gonna kill you", + "im gonna kill you", + "i will murder you", + "murder you", + "shoot you", + "stab you", + "beat you up", + "break your neck", + "burn your house down", + "dox you", + "doxx you", + "find your address", + "post your address", + "leak your address", + "leak your phone number", + "swat you", + "send cops to your house", + "rape you" +]; + +const spamScamPhrases: string[] = [ + "buy followers", + "cheap followers", + "free instagram followers", + "1000 followers", + "gain followers fast", + "boost your followers", + "instagram growth service", + "double your bitcoin", + "guaranteed crypto returns", + "risk free crypto", + "crypto giveaway", + "claim free crypto", + "free nft mint", + "airdrop claim", + "connect your wallet to claim", + "validate your wallet", + "wallet verification", + "send seed phrase", + "share your seed phrase", + "send recovery phrase", + "share recovery phrase", + "guaranteed profit", + "forex signals", + "whatsapp investment", + "click here to claim prize", + "you have won a prize", + "verify your account now", + "urgent account verification", + "cheap viagra" +]; + +const defaultAutomodRules: DefaultAutomodRule[] = [ + keywordListRule("Default pack: hate slurs", hateSlurs, "reject"), + keywordListRule("Default pack: review slurs", reviewOnlySlurs, "review"), + keywordListRule("Default pack: hate abuse", hateAbusePhrases, "review"), + keywordListRule("Default pack: harassment threats", harassmentThreatPhrases, "review"), + keywordListRule("Default pack: spam scams", spamScamPhrases, "reject") +]; + +export function installDefaultAutomodRules() { + if (settingExists(defaultAutomodRulesInstallKey)) return; + + sqlite.transaction(() => { + if (settingExists(defaultAutomodRulesInstallKey)) return; + insertDefaultRules(defaultAutomodRules); + saveSetting(defaultAutomodRulesInstallKey, new Date().toISOString()); + })(); +} + +function keywordListRule(name: string, terms: string[], action: AutomodAction): DefaultAutomodRule { + return { + name, + pattern: terms.join("\n"), + patternType: "keyword", + scope: "all", + action + }; +} + +function insertDefaultRules(rules: DefaultAutomodRule[]) { + const insertRule = sqlite.prepare( + `INSERT INTO automod_rules (name, pattern, pattern_type, scope, action, enabled, created_by) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ); + for (const rule of rules) insertRule.run(rule.name, rule.pattern, rule.patternType, rule.scope, rule.action, 1); +} diff --git a/src/server/moderation/automodNormalize.ts b/src/server/moderation/automodNormalize.ts new file mode 100644 index 0000000..137d78a --- /dev/null +++ b/src/server/moderation/automodNormalize.ts @@ -0,0 +1,197 @@ +// Folds text before automod matching so substitution tricks do not slip past +// rules. NFKD plus stripping combining marks and zero-width chars handles +// most Unicode lookalikes; the homoglyph table catches glyphs that survive +// normalization (Cyrillic \u0430, Greek \u03B1, etc.); the leetspeak table handles +// digit and symbol swaps (4 to a, 1 to i, $ to s). Each scan emits several +// variants (folded, squeezed, compact) so one rule matches across all of +// them. Pruning entries from these tables weakens the bypass coverage. + +const zeroWidthPattern = /[\u200B-\u200F\uFEFF]/g; +const combiningMarksPattern = /\p{Mark}/gu; +const nonAutomodTokenPattern = /[^a-z0-9]+/g; +const repeatedAutomodCharPattern = /([a-z0-9])\1{2,}/g; + +const homoglyphs: Record = { + "Α": "a", + "α": "a", + "А": "a", + "а": "a", + "ɑ": "a", + "Β": "b", + "β": "b", + "В": "b", + "Ь": "b", + "Ƅ": "b", + "ϲ": "c", + "С": "c", + "с": "c", + "ԁ": "d", + "Ε": "e", + "ε": "e", + "Е": "e", + "е": "e", + "Ϝ": "f", + "ɡ": "g", + "Η": "h", + "η": "h", + "Н": "h", + "һ": "h", + "Ι": "i", + "ι": "i", + "І": "i", + "і": "i", + "Ј": "j", + "ј": "j", + "Κ": "k", + "к": "k", + "К": "k", + "κ": "k", + "Μ": "m", + "м": "m", + "М": "m", + "Ν": "n", + "ν": "n", + "О": "o", + "Ο": "o", + "ο": "o", + "о": "o", + "Ρ": "p", + "ρ": "p", + "Р": "p", + "р": "p", + "Տ": "s", + "ѕ": "s", + "Τ": "t", + "т": "t", + "Т": "t", + "τ": "t", + "υ": "u", + "Χ": "x", + "χ": "x", + "Х": "x", + "х": "x", + "Υ": "y", + "γ": "y", + "у": "y", + "Ζ": "z", + "ζ": "z" +}; + +const leetspeak: Record = { + "@": "a", + "4": "a", + "8": "b", + "(": "c", + "3": "e", + "6": "g", + "9": "g", + "!": "i", + "1": "i", + "|": "i", + "0": "o", + "$": "s", + "5": "s", + "7": "t", + "+": "t", + "2": "z" +}; + +const leetspeakWithTallGlyphsAsL: Record = { + ...leetspeak, + "!": "l", + "1": "l", + "|": "l" +}; + +const regexSubstitutions: Record = { + a: "a@4", + b: "b8", + c: "c(", + e: "e3", + g: "g69", + i: "i1!|", + l: "l1!|", + o: "o0", + s: "s$5z2", + t: "t7+", + z: "z2" +}; + +export type AutomodScanText = { + raw: string; + folded: string; + squeezed: string; + compact: string; + compactSqueezed: string; + variants: string[]; +}; + +export function createAutomodScanText(text: string): AutomodScanText { + const raw = text.replace(/\s+/g, " ").trim(); + const folded = normalizeFoldedAutomodText(raw, leetspeak); + const foldedTallGlyphsAsL = normalizeFoldedAutomodText(raw, leetspeakWithTallGlyphsAsL); + const squeezed = folded.replace(repeatedAutomodCharPattern, "$1"); + const squeezedTallGlyphsAsL = foldedTallGlyphsAsL.replace(repeatedAutomodCharPattern, "$1"); + const compact = folded.replace(nonAutomodTokenPattern, ""); + const compactSqueezed = squeezed.replace(nonAutomodTokenPattern, ""); + const compactTallGlyphsAsL = foldedTallGlyphsAsL.replace(nonAutomodTokenPattern, ""); + const compactSqueezedTallGlyphsAsL = squeezedTallGlyphsAsL.replace(nonAutomodTokenPattern, ""); + const variants = uniqueAutomodTexts([ + raw, + folded, + squeezed, + compact, + compactSqueezed, + foldedTallGlyphsAsL, + squeezedTallGlyphsAsL, + compactTallGlyphsAsL, + compactSqueezedTallGlyphsAsL + ]); + return { raw, folded, squeezed, compact, compactSqueezed, variants }; +} + +export function automodLiteralPattern(literal: string) { + const words = createAutomodScanText(literal).folded.split(" ").filter(Boolean); + if (!words.length) return "(?!)"; + const pattern = words.map(evasiveWordPattern).join("[^a-z0-9]*"); + return `(?:^|[^a-z0-9])${pattern}(?=$|[^a-z0-9])`; +} + +function normalizeFoldedAutomodText(text: string, leetspeakMap: Record) { + return foldAutomodText(text, leetspeakMap).replace(nonAutomodTokenPattern, " ").trim(); +} + +function foldAutomodText(text: string, leetspeakMap: Record) { + return Array.from( + text + .normalize("NFKC") + .normalize("NFKD") + .replace(combiningMarksPattern, "") + .replace(zeroWidthPattern, "") + .toLowerCase() + ) + .map((char) => leetspeakMap[char] ?? homoglyphs[char] ?? char) + .join(""); +} + +function evasiveWordPattern(word: string) { + return Array.from(word).map(evasiveCharPattern).join("[^a-z0-9]*"); +} + +function evasiveCharPattern(char: string) { + const substitutions = regexSubstitutions[char]; + if (!substitutions) return `${escapeRegex(char)}+`; + return `[${escapeRegexCharClass(substitutions)}]+`; +} + +function escapeRegex(value: string) { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + +function escapeRegexCharClass(value: string) { + return value.replace(/[\\\]^$-]/g, "\\$&"); +} + +function uniqueAutomodTexts(texts: string[]) { + return Array.from(new Set(texts)); +} diff --git a/src/server/moderation/guards.ts b/src/server/moderation/guards.ts new file mode 100644 index 0000000..91a1dea --- /dev/null +++ b/src/server/moderation/guards.ts @@ -0,0 +1,13 @@ +import { getCurrentUser } from "../db/users.js"; +import { canModerateTarget } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; + +export function canModerateAuthor(actor: CurrentUser, authorId: number | null | undefined) { + if (!authorId) return false; + const target = getCurrentUser(authorId); + return Boolean(target && canModerateTarget(actor, target)); +} + +export function canDeleteAsOwnerOrModerator(actor: CurrentUser, authorId: number | null | undefined, ownerIds: Array) { + return ownerIds.some((id) => id === actor.id) || canModerateAuthor(actor, authorId); +} diff --git a/src/server/pagination.ts b/src/server/pagination.ts new file mode 100644 index 0000000..42d1436 --- /dev/null +++ b/src/server/pagination.ts @@ -0,0 +1,91 @@ +import { recordFromUnknown } from "../values.js"; + +export const beforeParam = "before"; + +type KeysetCursor = { + createdAt: string; + id: number; +}; + +export type PageOptions = { + before?: string | null; + limit?: number; +}; + +type Page = { + items: T[]; + nextCursor: string | null; +}; + +type KeysetRow = { + createdAt: string; + id: number; +}; + +const cursorVersion = 1; +const maxCursorLength = 512; + +export function decodeKeysetCursor(value?: string | null): KeysetCursor | null { + if (!value) return null; + if (value.length > maxCursorLength) return null; + + try { + const decoded = recordFromUnknown(JSON.parse(Buffer.from(value, "base64url").toString("utf8"))); + if (decoded.v !== cursorVersion) return null; + const createdAt = decoded.createdAt; + if (typeof createdAt !== "string" || !createdAt) return null; + const id = decoded.id; + if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 1) return null; + return { createdAt, id }; + } catch { + return null; + } +} + +function encodeKeysetCursor(row: KeysetRow) { + return Buffer.from(JSON.stringify({ v: cursorVersion, createdAt: row.createdAt, id: row.id })).toString("base64url"); +} + +export function normalizePageLimit(limit: number | undefined, fallback: number, max: number) { + const value = typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : fallback; + return Math.min(value, max); +} + +export function keysetBeforeCondition(cursor: KeysetCursor | null, timestampSql: string, idSql: string): { sql: string; params: unknown[] } { + if (!cursor) return { sql: "", params: [] }; + return { + sql: `AND (${timestampSql} < ? OR (${timestampSql} = ? AND ${idSql} < ?))`, + params: [cursor.createdAt, cursor.createdAt, cursor.id] + }; +} + +/** + * Builds a keyset page from a query result. Caller must fetch `limit + 1` + * rows; the extra row is what signals "there is a next page". If only + * `limit` (or fewer) rows came back, nextCursor is null. + */ +export function pageFromRows(rows: T[], limit: number, cursorFor: (row: T) => KeysetRow = (row) => row): Page { + const items = rows.slice(0, limit); + const lastItem = items.at(-1); + const nextCursor = rows.length > limit && lastItem ? encodeKeysetCursor(cursorFor(lastItem)) : null; + return { items, nextCursor }; +} + +export function previewFromRows(rows: T[], limit: number) { + return { + items: rows.slice(0, limit), + hasMore: rows.length > limit + }; +} + +export function paginationHref(path: string, cursor: string | null, param = beforeParam) { + const [base, query = ""] = path.split("?", 2); + const params = new URLSearchParams(query); + if (cursor) { + params.set(param, cursor); + } else { + params.delete(param); + } + const nextQuery = params.toString(); + return nextQuery ? `${base}?${nextQuery}` : base; +} diff --git a/src/server/rateLimit.test.ts b/src/server/rateLimit.test.ts new file mode 100644 index 0000000..5fef1e6 --- /dev/null +++ b/src/server/rateLimit.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database as SqliteDatabase } from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppContext } from "./context.js"; + +let db: SqliteDatabase | undefined; +let tmpDir: string | undefined; + +async function loadIsolatedRateLimiter() { + vi.resetModules(); + tmpDir = mkdtempSync(join(tmpdir(), "bliishspace-rate-limit-subject-")); + process.env.BLIISH_DATABASE_PATH = join(tmpDir, "test.sqlite"); + process.env.BLIISH_UPLOAD_DIR = join(tmpDir, "uploads"); + + const client = await import("./db/client.js"); + db = client.sqlite; + const schema = await import("./db/schema.js"); + schema.initializeDatabase(); + + return await import("./rateLimit.js"); +} + +afterEach(() => { + db?.close(); + db = undefined; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + delete process.env.BLIISH_DATABASE_PATH; + delete process.env.BLIISH_UPLOAD_DIR; +}); + +describe("action rate limits", () => { + it("requires form subject data when no account is signed in", async () => { + const { assertActionRateLimit } = await loadIsolatedRateLimiter(); + + expect(() => assertActionRateLimit(publicFormContext(), "auth.signup")).toThrow(); + }); + + it("rate limits public forms by the submitted subject", async () => { + const { assertActionRateLimit } = await loadIsolatedRateLimiter(); + + for (let index = 0; index < 5; index += 1) { + assertActionRateLimit(publicFormContext(), "auth.signup", "email:jane@example.test"); + } + + expect(() => assertActionRateLimit(publicFormContext(), "auth.signup", "email:jane@example.test")).toThrow(); + expect(() => assertActionRateLimit(publicFormContext(), "auth.signup", "email:alex@example.test")).not.toThrow(); + }); +}); + +function publicFormContext() { + return { + get: () => null, + req: { + header: () => undefined + } + } as unknown as AppContext; +} diff --git a/src/server/rateLimit.ts b/src/server/rateLimit.ts new file mode 100644 index 0000000..ab52211 --- /dev/null +++ b/src/server/rateLimit.ts @@ -0,0 +1,27 @@ +import { HTTPException } from "hono/http-exception"; +import { currentUser } from "./auth/session.js"; +import { consumeRateLimit, rateLimitPolicyFor } from "./db/rateLimits.js"; +import { rateLimits, type RateLimitAction } from "../policy.js"; +import { sha256 } from "./security/crypto.js"; +import type { AppContext } from "./context.js"; + +export function assertActionRateLimit(c: AppContext, action: RateLimitAction, publicFormSubject?: string) { + const policy = rateLimitPolicyFor(action); + const subject = rateLimitSubject(c, publicFormSubject); + const allowed = consumeRateLimit({ + action, + subjectHash: sha256(subject), + limit: policy.limit, + pruneAfterSeconds: rateLimits.pruneAfterSeconds, + windowSeconds: policy.windowSeconds + }); + + if (!allowed) throw new HTTPException(429, { message: rateLimits.message }); +} + +function rateLimitSubject(c: AppContext, publicFormSubject: string | undefined) { + const user = currentUser(c); + if (user) return `user:${user.id}`; + if (publicFormSubject) return publicFormSubject; + throw new HTTPException(400, { message: "This form is missing rate-limit subject data." }); +} diff --git a/src/server/render.tsx b/src/server/render.tsx new file mode 100644 index 0000000..a27876a --- /dev/null +++ b/src/server/render.tsx @@ -0,0 +1,26 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { currentUser } from "./auth/session.js"; +import type { AppContext } from "./context.js"; +import { PlainPage } from "../shell/index.js"; +import { resolveIndexing } from "./indexing/routes.js"; +import type { PageSeo } from "../settings/seo.js"; +import type { ViewChild } from "../ui/types.js"; + +export function plainPage(c: AppContext, title: string, body: ViewChild, status = 200, seo?: PageSeo) { + const resolvedSeo = status === 200 ? plainPageSeo(c) : undefined; + const pageSeo = resolvedSeo || seo ? { ...resolvedSeo, ...seo } : undefined; + return c.html(, contentfulStatus(status)); +} + +function contentfulStatus(status: number): ContentfulStatusCode { + return isContentfulStatus(status) ? status : 500; +} + +function isContentfulStatus(status: number): status is ContentfulStatusCode { + return status >= 200 && status < 600 && ![204, 205, 304].includes(status); +} + +function plainPageSeo(c: AppContext) { + const decision = resolveIndexing(c); + return decision.index ? { canonicalPath: decision.canonicalPath } : undefined; +} diff --git a/src/server/security/crypto.ts b/src/server/security/crypto.ts new file mode 100644 index 0000000..8d06398 --- /dev/null +++ b/src/server/security/crypto.ts @@ -0,0 +1,15 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export function randomToken(bytes = 32) { + return randomBytes(bytes).toString("base64url"); +} + +export function sha256(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +export function safeEqual(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} diff --git a/src/server/security/css.ts b/src/server/security/css.ts new file mode 100644 index 0000000..66b6043 --- /dev/null +++ b/src/server/security/css.ts @@ -0,0 +1,270 @@ +import { profileSkinPartSet, profileSkinVersion } from "../../skins/contract.js"; +import { normalizeResourceUrl } from "./urls.js"; + +const blockedCssProperties = new Set(["behavior", "-moz-binding"]); +const blockedCustomPropertyPrefixes = ["--theme-", "--app-theme-", "--color-"]; +const skinVersionSelectorSource = String.raw`\[data-skin-version\s*=\s*(?:"${profileSkinVersion}"|'${profileSkinVersion}'|${profileSkinVersion})\s*\]`; +const skinVersionSelectorPattern = new RegExp(skinVersionSelectorSource); +const skinVersionSelectorGlobalPattern = new RegExp(skinVersionSelectorSource, "g"); +const importRulePattern = /@import\s+(?:url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)|"([^"]+)"|'([^']+)'|([^;\s]+))[^;]*;/gi; + +export function sanitizeInlineStyle(input: string) { + return sanitizeDeclarations(input); +} + +export function sanitizeStyleBlocks(input: string) { + return input.replace(/]*>([\s\S]*?)<\/style>/gi, (_match, css: string) => { + const sanitized = sanitizeSkinCss(css); + return sanitized ? `` : ""; + }); +} + +function sanitizeSkinCss(input: string) { + const imports: string[] = []; + const css = input + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(importRulePattern, (_rule, doubleQuotedUrl: string, singleQuotedUrl: string, unquotedUrl: string, bareDoubleQuotedUrl: string, bareSingleQuotedUrl: string, bareUnquotedUrl: string) => { + const rawUrl = doubleQuotedUrl || singleQuotedUrl || unquotedUrl || bareDoubleQuotedUrl || bareSingleQuotedUrl || bareUnquotedUrl; + const url = normalizeResourceUrl(rawUrl); + // The CSP permits Google Fonts CSS for user skins; all other imports are + // dropped so skins cannot pull arbitrary remote stylesheets. + if (url?.startsWith("https://fonts.googleapis.com/")) imports.push(`@import url("${url.replace(/"/g, "%22")}");`); + return ""; + }) + .replace(/<\/?style\b[^>]*>/gi, "") + .replace(/[<>]/g, ""); + + return `${imports.join("\n")}${imports.length ? "\n" : ""}${sanitizeCssBlock(css)}`.trim(); +} + +function sanitizeCssBlock(css: string) { + // This accepts simple balanced CSS rule blocks and drops malformed trailing + // input instead of trying to repair ambiguous CSS. + let output = ""; + let index = 0; + + while (index < css.length) { + const open = css.indexOf("{", index); + if (open === -1) break; + + const prelude = css.slice(index, open).trim(); + const close = matchingBrace(css, open); + if (close === -1) break; + + const body = css.slice(open + 1, close); + const rule = prelude.startsWith("@") ? sanitizeAtRule(prelude, body) : sanitizeStyleRule(prelude, body); + if (rule) output += rule; + index = close + 1; + } + + return output; +} + +function sanitizeAtRule(prelude: string, body: string) { + const lower = prelude.toLowerCase(); + + if (lower.startsWith("@media") && /^@media[-a-zA-Z0-9\s:().,%/]+$/.test(prelude)) { + const nested = sanitizeCssBlock(body); + return nested ? `${prelude}{${nested}}` : ""; + } + + if (lower.startsWith("@keyframes") && /^@keyframes\s+[-_a-zA-Z0-9]+$/.test(prelude)) { + const nested = sanitizeKeyframes(body); + return nested ? `${prelude}{${nested}}` : ""; + } + + return ""; +} + +function sanitizeStyleRule(selector: string, body: string) { + if (!isSafeSelector(selector)) return ""; + const declarations = sanitizeDeclarations(body); + return declarations ? `${selector}{${declarations}}` : ""; +} + +function isSafeSelector(selector: string) { + return selector.length <= 500 && selector.split(",").every(isSafeSelectorBranch); +} + +function isSafeSelectorBranch(selector: string) { + if (!hasSupportedSkinHook(selector)) return false; + const normalizedSelector = normalizeSupportedSkinSelectors(selector); + // Attribute selectors and selector functions can target app-owned DOM + // state in surprising ways. Keep only the public profile skin hooks. + if (/[\[\]<>]/.test(normalizedSelector)) return false; + if (/:has|:is|:where/i.test(normalizedSelector)) return false; + const withoutSimpleNot = normalizedSelector.replace(/:not\([-_a-zA-Z0-9\s.#]+\)/g, ""); + if (/:not/i.test(withoutSimpleNot)) return false; + if (!isProfileScopedSelector(selector, normalizedSelector)) return false; + return /^[-_a-zA-Z0-9\s.#:>+~,()*="'|%^$]+$/.test(normalizedSelector); +} + +function hasSupportedSkinHook(selector: string) { + if (/\[data-skin-page\]/.test(selector)) return true; + if (/\[data-skin-root\]/.test(selector)) return true; + if (skinVersionSelectorPattern.test(selector)) return true; + return hasSupportedSkinPartHook(selector); +} + +function isProfileScopedSelector(selector: string, normalizedSelector: string) { + if (/\[data-skin-root\]/.test(selector)) return true; + if (hasSupportedSkinPartHook(selector)) return true; + + // Bare page selectors are limited to the hook. Descendant rules should + // target public shell/profile part hooks instead of arbitrary app classes. + return isPageLevelSelector(selector, normalizedSelector); +} + +function isPageLevelSelector(selector: string, normalizedSelector: string) { + if (!/\[data-skin-page\]/.test(selector) && !skinVersionSelectorPattern.test(selector)) return false; + if (/\[data-skin-root\]/.test(selector) || hasSupportedSkinPartHook(selector)) return false; + return normalizedSelector.replace(/\.skin-page|\.skin-version/g, "").trim() === ""; +} + +function hasSupportedSkinPartHook(selector: string) { + const partPattern = /\[data-skin-part\s*=\s*(?:"([^"]+)"|'([^']+)'|([-_a-zA-Z0-9]+))\s*\]/g; + for (const match of selector.matchAll(partPattern)) { + const part = (match[1] || match[2] || match[3] || "").toLowerCase(); + if (profileSkinPartSet.has(part)) return true; + } + return false; +} + +function normalizeSupportedSkinSelectors(selector: string) { + return selector + .replace(/\[data-skin-page\]/g, ".skin-page") + .replace(/\[data-skin-root\]/g, ".skin-root") + .replace(skinVersionSelectorGlobalPattern, ".skin-version") + .replace(/\[data-skin-part\s*=\s*(?:"([^"]+)"|'([^']+)'|([-_a-zA-Z0-9]+))\s*\]/g, (match, doubleQuoted: string, singleQuoted: string, unquoted: string) => { + const part = (doubleQuoted || singleQuoted || unquoted || "").toLowerCase(); + return profileSkinPartSet.has(part) ? ".skin-part" : match; + }); +} + +function sanitizeKeyframes(css: string) { + let output = ""; + let index = 0; + + while (index < css.length) { + const open = css.indexOf("{", index); + if (open === -1) break; + + const selector = css.slice(index, open).trim(); + const close = matchingBrace(css, open); + if (close === -1) break; + + const declarations = isSafeKeyframeSelector(selector) ? sanitizeDeclarations(css.slice(open + 1, close)) : ""; + if (declarations) output += `${selector}{${declarations}}`; + index = close + 1; + } + + return output; +} + +function isSafeKeyframeSelector(selector: string) { + return /^(from|to|(?:100|[1-9]?[0-9])(?:\.\d+)?%)$/i.test(selector); +} + +function sanitizeDeclarations(input: string) { + return splitDeclarations(input) + .map((declaration) => { + const colon = declaration.indexOf(":"); + if (colon === -1) return ""; + + const property = declaration.slice(0, colon).trim().toLowerCase(); + const value = declaration.slice(colon + 1).trim(); + if (!isSafeCssProperty(property)) return ""; + const safeValue = sanitizeCssValue(property, value); + return safeValue ? `${property}:${safeValue};` : ""; + }) + .join(""); +} + +function splitDeclarations(input: string) { + // Split on semicolons outside quoted strings and CSS functions so url(...) + // values are validated as a single declaration. + const declarations: string[] = []; + let current = ""; + let quote = ""; + let depth = 0; + + for (const char of input) { + if (quote) { + current += char; + if (char === quote) quote = ""; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + + if (char === "(") depth += 1; + if (char === ")" && depth > 0) depth -= 1; + + if (char === ";" && depth === 0) { + declarations.push(current); + current = ""; + } else { + current += char; + } + } + + if (current.trim()) declarations.push(current); + return declarations; +} + +function isSafeCssProperty(property: string) { + if (/^--/.test(property)) return /^--[-_a-z0-9]+$/.test(property) && !blockedCustomPropertyPrefixes.some((prefix) => property.startsWith(prefix)); + if (!/^-?[a-z][-_a-z0-9]*$/.test(property)) return false; + return !blockedCssProperties.has(property); +} + +function sanitizeCssValue(property: string, value: string) { + if (!value || /[<>]/.test(value)) return ""; + // Reject CSS escapes to avoid obfuscated spellings such as u\72l(...). + if (/\\/.test(value)) return ""; + if (/expression\s*\(|javascript\s*:|vbscript\s*:|behavior\s*:|-moz-binding/i.test(value)) return ""; + if (/\battr\s*\(/i.test(value)) return ""; + if (property === "position" && /\b(absolute|fixed|sticky)\b/i.test(value)) return ""; + + let rejectedUrl = false; + const withSafeUrls = value.replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (_match, _quote, rawUrl: string) => { + const url = normalizeResourceUrl(rawUrl); + if (!url) { + rejectedUrl = true; + return ""; + } + return `url("${url.replace(/"/g, "%22")}")`; + }); + + return rejectedUrl ? "" : withSafeUrls; +} + +function matchingBrace(input: string, open: number) { + let depth = 0; + let quote = ""; + + for (let index = open; index < input.length; index += 1) { + const char = input[index]; + if (quote) { + if (char === quote && input[index - 1] !== "\\") quote = ""; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; +} diff --git a/src/server/security/embeds.ts b/src/server/security/embeds.ts new file mode 100644 index 0000000..933c210 --- /dev/null +++ b/src/server/security/embeds.ts @@ -0,0 +1,157 @@ +const soundCloudResourceHosts = new Set(["api.soundcloud.com", "soundcloud.com", "www.soundcloud.com"]); +const spotifyEmbedTypes = new Set(["album", "artist", "audiobook", "episode", "playlist", "show", "track"]); + +// Embed URLs are normalized to these hosts before rendering. +export const embedSandbox = "allow-scripts allow-same-origin allow-presentation allow-popups"; +export const embedReferrerPolicy = "strict-origin-when-cross-origin"; + +export function normalizeEmbedUrl(value: string) { + const trimmed = value.trim().replace(/[\u0000-\u001f\u007f\s]+/g, ""); + if (!trimmed || /^(javascript|vbscript|file|data):/i.test(trimmed)) return ""; + + try { + const url = new URL(trimmed); + if (url.protocol !== "https:") return ""; + + const hostname = url.hostname.toLowerCase(); + if (hostname === "youtu.be") return youtubeEmbedUrl(url.pathname.slice(1), url.searchParams); + + if (hostname === "www.youtube.com" || hostname === "youtube.com" || hostname === "m.youtube.com" || hostname === "www.youtube-nocookie.com") { + if (url.pathname === "/watch") return youtubeEmbedUrl(url.searchParams.get("v") ?? "", url.searchParams); + if (url.pathname.startsWith("/shorts/")) return youtubeEmbedUrl(url.pathname.split("/")[2] ?? "", url.searchParams); + if (url.pathname.startsWith("/embed/")) return youtubeEmbedUrl(url.pathname.slice("/embed/".length), url.searchParams); + } + + if (hostname === "w.soundcloud.com" && url.pathname === "/player/" && isSafeSoundCloudWidget(url)) return url.href; + if ((hostname === "vimeo.com" || hostname === "www.vimeo.com" || hostname === "player.vimeo.com") && isSafeVimeoUrl(url)) return vimeoEmbedUrl(url); + if (hostname === "open.spotify.com" && isSafeSpotifyUrl(url)) return spotifyEmbedUrl(url); + if (hostname === "bandcamp.com" && isSafeBandcampPlayer(url)) return url.href; + if ((hostname === "www.tiktok.com" || hostname === "tiktok.com") && isSafeTikTokUrl(url)) return tiktokEmbedUrl(url); + if ((hostname === "www.dailymotion.com" || hostname === "dailymotion.com" || hostname === "dai.ly") && isSafeDailymotionUrl(url)) return dailymotionEmbedUrl(url); + + return ""; + } catch { + return ""; + } +} + +export function embedAllowPolicy(src: string) { + const hostname = new URL(src).hostname; + if (hostname === "w.soundcloud.com" || hostname === "bandcamp.com") return "autoplay"; + if (hostname === "open.spotify.com") return "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"; + if (hostname === "player.vimeo.com") return "autoplay; fullscreen; picture-in-picture"; + if (hostname === "www.tiktok.com") return "encrypted-media; fullscreen; picture-in-picture"; + if (hostname === "www.dailymotion.com") return "autoplay; fullscreen; picture-in-picture"; + return "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"; +} + +function youtubeEmbedUrl(rawId: string, params: URLSearchParams) { + const [id] = rawId.split(/[/?#]/); + const embed = new URL("https://www.youtube-nocookie.com/embed/"); + const list = params.get("list"); + + if (id === "videoseries" && list) { + embed.pathname = "/embed/videoseries"; + embed.searchParams.set("list", list); + return embed.href; + } + + if (!/^[a-zA-Z0-9_-]{6,64}$/.test(id)) return ""; + embed.pathname = `/embed/${id}`; + if (list && /^[a-zA-Z0-9_-]{6,80}$/.test(list)) embed.searchParams.set("list", list); + return embed.href; +} + +function isSafeSoundCloudWidget(url: URL) { + const embeddedUrl = url.searchParams.get("url"); + if (!embeddedUrl) return true; + + try { + const parsed = new URL(embeddedUrl); + return parsed.protocol === "https:" && soundCloudResourceHosts.has(parsed.hostname.toLowerCase()); + } catch { + return false; + } +} + +function isSafeVimeoUrl(url: URL) { + if (url.hostname.toLowerCase() === "player.vimeo.com") { + return /^\/video\/\d+$/.test(url.pathname) && safeQueryParams(url.searchParams, ["h", "badge", "autopause", "player_id", "app_id"]); + } + const parts = pathParts(url); + return parts.length >= 1 && /^\d+$/.test(parts[0]) && (!parts[1] || /^[a-zA-Z0-9_-]{6,64}$/.test(parts[1])); +} + +function vimeoEmbedUrl(url: URL) { + const embed = new URL("https://player.vimeo.com/video/"); + if (url.hostname.toLowerCase() === "player.vimeo.com") { + embed.pathname = url.pathname; + copySafeParams(url.searchParams, embed.searchParams, ["h"]); + return embed.href; + } + + const [id, hash] = pathParts(url); + embed.pathname = `/video/${id}`; + const h = url.searchParams.get("h") ?? hash; + if (h && /^[a-zA-Z0-9_-]{6,64}$/.test(h)) embed.searchParams.set("h", h); + return embed.href; +} + +function isSafeSpotifyUrl(url: URL) { + const parts = pathParts(url).filter((part) => !/^[a-z]{2}(?:-[a-z]{2})?$/i.test(part)); + const offset = parts[0] === "embed" ? 1 : 0; + const type = parts[offset]; + const id = parts[offset + 1]; + return Boolean(type && id && spotifyEmbedTypes.has(type) && /^[a-zA-Z0-9]{12,40}$/.test(id)); +} + +function spotifyEmbedUrl(url: URL) { + const parts = pathParts(url).filter((part) => !/^[a-z]{2}(?:-[a-z]{2})?$/i.test(part)); + const offset = parts[0] === "embed" ? 1 : 0; + return `https://open.spotify.com/embed/${parts[offset]}/${parts[offset + 1]}`; +} + +function isSafeBandcampPlayer(url: URL) { + return /^\/EmbeddedPlayer\/(?:[-_a-zA-Z0-9=]+\/)*$/.test(url.pathname) && /\/(?:album|track)=\d+\//.test(`${url.pathname}/`); +} + +function isSafeTikTokUrl(url: URL) { + const parts = pathParts(url); + if (parts[0] === "player" && parts[1] === "v1") return /^\d{8,32}$/.test(parts[2] ?? ""); + const videoIndex = parts.indexOf("video"); + return videoIndex !== -1 && /^\d{8,32}$/.test(parts[videoIndex + 1] ?? ""); +} + +function tiktokEmbedUrl(url: URL) { + const parts = pathParts(url); + const id = parts[0] === "player" && parts[1] === "v1" ? parts[2] : parts[parts.indexOf("video") + 1]; + return `https://www.tiktok.com/player/v1/${id}`; +} + +function isSafeDailymotionUrl(url: URL) { + const parts = pathParts(url); + if (url.hostname.toLowerCase() === "dai.ly") return /^[a-zA-Z0-9]+$/.test(parts[0] ?? ""); + if (parts[0] === "embed" && parts[1] === "video") return /^[a-zA-Z0-9]+$/.test(parts[2] ?? ""); + return parts[0] === "video" && /^[a-zA-Z0-9]+$/.test(parts[1] ?? ""); +} + +function dailymotionEmbedUrl(url: URL) { + const parts = pathParts(url); + const id = url.hostname.toLowerCase() === "dai.ly" ? parts[0] : parts[0] === "embed" ? parts[2] : parts[1]; + return `https://www.dailymotion.com/embed/video/${id}`; +} + +function pathParts(url: URL) { + return url.pathname.split("/").filter(Boolean); +} + +function safeQueryParams(params: URLSearchParams, allowed: string[]) { + return [...params.keys()].every((key) => allowed.includes(key)); +} + +function copySafeParams(from: URLSearchParams, to: URLSearchParams, names: string[]) { + for (const name of names) { + const value = from.get(name); + if (value && /^[a-zA-Z0-9_-]{1,80}$/.test(value)) to.set(name, value); + } +} diff --git a/src/server/security/headers.ts b/src/server/security/headers.ts new file mode 100644 index 0000000..996abc0 --- /dev/null +++ b/src/server/security/headers.ts @@ -0,0 +1,32 @@ +const frameSources = [ + "https://www.youtube-nocookie.com", + "https://www.youtube.com", + "https://w.soundcloud.com", + "https://player.vimeo.com", + "https://open.spotify.com", + "https://bandcamp.com", + "https://www.tiktok.com", + "https://www.dailymotion.com" +]; + +const contentSecurityPolicy = [ + "default-src 'self'", + "script-src 'self'", + "script-src-attr 'none'", + "img-src 'self' data: https:", + "media-src 'self'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' data: https://fonts.gstatic.com", + `frame-src ${frameSources.join(" ")}`, + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'" +].join("; "); + +export const securityHeaders = { + "Content-Security-Policy": contentSecurityPolicy, + "Referrer-Policy": "same-origin", + "X-Content-Type-Options": "nosniff", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()" +}; diff --git a/src/server/security/html.test.ts b/src/server/security/html.test.ts new file mode 100644 index 0000000..7a4751b --- /dev/null +++ b/src/server/security/html.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeSkinHtml, sanitizeUserText } from "./html.js"; + +describe("html sanitizers", () => { + it("keeps user text inert", () => { + const html = sanitizeUserText('linktext'); + + expect(html).toContain("link"); + expect(html).toContain("text"); + expect(html).not.toContain(""); + }); + + it("strips active code and unsafe links from skin HTML", () => { + const html = sanitizeSkinHtml( + [ + '
', + 'link', + '', + '
' + ].join("") + ); + + expect(html).not.toContain(" { + const html = sanitizeSkinHtml( + [ + "" + ].join("") + ); + + expect(html).toContain('[data-skin-part="bio"]'); + expect(html).not.toContain("value^="); + expect(html).not.toContain("evil.example"); + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("position:fixed"); + expect(html).not.toContain("body"); + }); + + it("keeps quoted Google Fonts imports with semicolons in the URL", () => { + const html = sanitizeSkinHtml( + [ + "" + ].join("") + ); + + expect(html).toContain('@import url("https://fonts.googleapis.com/css2?family=Libre+Franklin:ital,wght@0,100;0,600;1,600&display=swap");'); + expect(html).toContain("[data-skin-page]{background:#007f7e!important;}"); + expect(html).toContain('[data-skin-part="name"]{width:100%;}'); + }); + + it("sandboxes only whitelisted media embeds", () => { + const html = sanitizeSkinHtml( + [ + '', + '' + ].join("") + ); + + expect(html).toContain("https://www.youtube-nocookie.com/embed/"); + expect(html).toContain("sandbox="); + expect(html).toContain('referrerpolicy="strict-origin-when-cross-origin"'); + expect(html).not.toContain("evil.example"); + expect(html).not.toContain("onload"); + expect(html).not.toContain("allow-forms"); + }); +}); diff --git a/src/server/security/html.ts b/src/server/security/html.ts new file mode 100644 index 0000000..5a150a4 --- /dev/null +++ b/src/server/security/html.ts @@ -0,0 +1,3 @@ +export { sanitizeBlogBody, sanitizeLinkedText, sanitizeUserText } from "./textToHtml.js"; +export { sanitizeSkinHtml } from "./skinHtml.js"; +export { blogBodyTextFromHtml, plainTextFromHtml, userTextFromHtml } from "./htmlToText.js"; diff --git a/src/server/security/htmlToText.ts b/src/server/security/htmlToText.ts new file mode 100644 index 0000000..a874a1d --- /dev/null +++ b/src/server/security/htmlToText.ts @@ -0,0 +1,107 @@ +import sanitizeHtml from "sanitize-html"; + +export function userTextFromHtml(input: string) { + const anchors: string[] = []; + const withAnchorTokens = input.replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attributes: string, textHtml: string): string => { + const href = hrefFromAttributes(attributes); + const label = editorText(textHtml.replace(//gi, "\n")); + const token = editorLinkToken(anchors.length); + anchors.push(href ? `${escapeHtmlText(label || href)}` : label); + return token; + }); + + return anchors.reduce( + (text, anchor, index) => text.replaceAll(editorLinkToken(index), anchor), + editorText(withAnchorTokens.replace(//gi, "\n")) + ); +} + +export function blogBodyTextFromHtml(input: string): string { + const editorMarkup: string = input + .replace(//gi, "\n") + .replace(/]*>([\s\S]*?)<\/h2>/gi, (_match, textHtml: string): string => `[h2]${blogBodyTextFromHtml(textHtml).trim()}[/h2]\n`) + .replace(/]*>([\s\S]*?)<\/h3>/gi, (_match, textHtml: string): string => `[h3]${blogBodyTextFromHtml(textHtml).trim()}[/h3]\n`) + .replace(/<(ul|ol)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, tagName: string, listHtml: string): string => listHtmlToBBCode(tagName, listHtml)) + .replace(/]*>/gi, "") + .replace(/<\/p>/gi, "\n\n") + .replace(/]*>/gi, "\n[quote]\n") + .replace(/<\/blockquote>/gi, "\n[/quote]\n") + .replace(/]*>/gi, "[code]") + .replace(/<\/code>/gi, "[/code]") + .replace(/<(?:b|strong)\b[^>]*>/gi, "[b]") + .replace(/<\/(?:b|strong)>/gi, "[/b]") + .replace(/<(?:i|em)\b[^>]*>/gi, "[i]") + .replace(/<\/(?:i|em)>/gi, "[/i]") + .replace(/]*>/gi, "[u]") + .replace(/<\/u>/gi, "[/u]") + .replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attributes: string, textHtml: string): string => { + const href = hrefFromAttributes(attributes); + const label: string = blogBodyTextFromHtml(textHtml).trim(); + if (!href) return label; + return !label || label === href ? `[url]${href}[/url]` : `[url=${href}]${label}[/url]`; + }); + + return editorText(editorMarkup); +} + +export function plainTextFromHtml(input: string) { + const spaced = input + .replace(//gi, " ") + .replace(/]*>/gi, " ") + .replace(/<\/(?:blockquote|div|h[1-6]|li|ol|p|tr|ul)>/gi, " "); + return decodeHtmlEntities(sanitizeHtml(spaced, { allowedTags: [], allowedAttributes: {} })).replace(/\s+/g, " ").trim(); +} + +function editorLinkToken(index: number) { + return `\uE000EDITOR_LINK_${index}\uE001`; +} + +function editorText(markup: string) { + return decodeHtmlEntities(sanitizeHtml(markup, { allowedTags: [], allowedAttributes: {} })) + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function listHtmlToBBCode(tagName: string, listHtml: string) { + const items = Array.from(listHtml.matchAll(/]*>([\s\S]*?)<\/li>/gi)) + .map((match) => blogBodyTextFromHtml(match[1] ?? "").trim()) + .filter(Boolean); + if (!items.length) return blogBodyTextFromHtml(listHtml); + const bbcodeTag = tagName.toLowerCase() === "ol" ? "olist" : "list"; + return `[${bbcodeTag}]\n${items.map((item) => `[*] ${item}`).join("\n")}\n[/${bbcodeTag}]\n`; +} + +function decodeHtmlEntities(input: string) { + return input.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity: string) => { + const normalized = entity.toLowerCase(); + if (normalized === "amp") return "&"; + if (normalized === "lt") return "<"; + if (normalized === "gt") return ">"; + if (normalized === "quot") return '"'; + if (normalized === "apos") return "'"; + if (normalized === "nbsp") return "\u00a0"; + const codePoint = normalized.startsWith("#x") ? Number.parseInt(normalized.slice(2), 16) : Number.parseInt(normalized.slice(1), 10); + if (!Number.isFinite(codePoint)) return match; + try { + return String.fromCodePoint(codePoint); + } catch { + return match; + } + }); +} + +function hrefFromAttributes(attributes: string) { + const match = attributes.match(/\shref=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + return decodeHtmlEntities(match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim(); +} + +function escapeHtmlText(input: string) { + return input.replace(/&/g, "&").replace(//g, ">"); +} + +function escapeHtmlAttribute(input: string) { + return escapeHtmlText(input).replace(/"/g, """); +} diff --git a/src/server/security/skinHtml.ts b/src/server/security/skinHtml.ts new file mode 100644 index 0000000..200f517 --- /dev/null +++ b/src/server/security/skinHtml.ts @@ -0,0 +1,153 @@ +import sanitizeHtml from "sanitize-html"; +import { sanitizeInlineStyle, sanitizeStyleBlocks } from "./css.js"; +import { embedAllowPolicy, embedReferrerPolicy, embedSandbox, normalizeEmbedUrl } from "./embeds.js"; +import { normalizeLinkUrl, normalizeResourceUrl } from "./urls.js"; + +const skinTags = [ + "a", + "b", + "big", + "blockquote", + "br", + "caption", + "center", + "code", + "div", + "em", + "font", + "h1", + "h2", + "h3", + "h4", + "hr", + "i", + "iframe", + "img", + "li", + "marquee", + "ol", + "p", + "s", + "small", + "span", + "strike", + "strong", + "style", + "sub", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul" +]; + +const skinAttributes = { + a: ["href", "title", "target", "rel", "class", "id", "style"], + font: ["face", "color", "size", "class", "id", "style"], + iframe: ["src", "title", "width", "height", "class", "id", "style", "loading", "allow", "allowfullscreen", "frameborder", "referrerpolicy", "sandbox"], + img: ["src", "alt", "title", "width", "height", "class", "id", "style", "loading", "align", "border"], + marquee: ["behavior", "direction", "scrollamount", "scrolldelay", "loop", "width", "height", "bgcolor", "align", "class", "id", "style"], + table: ["width", "height", "border", "cellpadding", "cellspacing", "align", "valign", "bgcolor", "background", "class", "id", "style"], + td: ["width", "height", "colspan", "rowspan", "align", "valign", "bgcolor", "background", "class", "id", "style"], + th: ["width", "height", "colspan", "rowspan", "align", "valign", "bgcolor", "background", "class", "id", "style"], + tr: ["align", "valign", "bgcolor", "background", "class", "id", "style"], + "*": ["class", "id", "style", "align", "title"] +}; + +const allowedIframeHostnames = [ + "bandcamp.com", + "open.spotify.com", + "player.vimeo.com", + "w.soundcloud.com", + "www.dailymotion.com", + "www.tiktok.com", + "www.youtube-nocookie.com", + "www.youtube.com" +]; + +export function sanitizeSkinHtml(input: string) { + const withoutActiveCode = input + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/javascript:/gi, "") + .replace(/behavior\s*:/gi, ""); + const withSanitizedCss = sanitizeStyleBlocks(withoutActiveCode); + + return sanitizeHtml(withSanitizedCss, { + allowedTags: skinTags, + allowedAttributes: skinAttributes, + allowedSchemes: ["http", "https", "mailto"], + allowedIframeHostnames, + allowVulnerableTags: true, + transformTags: { + "*": (tagName, attribs) => ({ tagName, attribs: sanitizeSkinAttributes(tagName, attribs) }), + a: (_tagName, attribs) => { + const sanitized = sanitizeSkinAttributes("a", attribs); + const href = typeof sanitized.href === "string" ? normalizeLinkUrl(sanitized.href) : ""; + if (href) sanitized.href = href; + else delete sanitized.href; + sanitized.rel = "nofollow noopener noreferrer"; + if (sanitized.target && sanitized.target !== "_blank") delete sanitized.target; + return { tagName: "a", attribs: sanitized }; + }, + img: (_tagName, attribs) => { + const sanitized = sanitizeSkinAttributes("img", attribs); + const src = typeof sanitized.src === "string" ? sanitized.src : ""; + const safeSrc = normalizeResourceUrl(src); + return safeSrc + ? { tagName: "img", attribs: { ...sanitized, src: safeSrc } } + : { tagName: "span", attribs: {}, text: "" }; + }, + iframe: (_tagName, attribs) => { + const sanitized = sanitizeSkinAttributes("iframe", attribs); + const src = typeof sanitized.src === "string" ? normalizeEmbedUrl(sanitized.src) : ""; + if (!src) return { tagName: "span", attribs: {}, text: "" }; + return { + tagName: "iframe", + attribs: { + ...sanitized, + src, + loading: "lazy", + referrerpolicy: embedReferrerPolicy, + sandbox: embedSandbox, + allow: embedAllowPolicy(src), + allowfullscreen: "" + } + }; + } + } + }); +} + +function sanitizeSkinAttributes(tagName: string, attribs: Record) { + const sanitized = { ...attribs }; + + if (typeof sanitized.style === "string") { + const style = sanitizeInlineStyle(sanitized.style); + if (style) sanitized.style = style; + else delete sanitized.style; + } + + for (const attr of ["src", "background"]) { + if (typeof sanitized[attr] !== "string") continue; + const url = normalizeResourceUrl(sanitized[attr]); + if (url) sanitized[attr] = url; + else delete sanitized[attr]; + } + + if (tagName === "img" && sanitized.loading && !/^(lazy|eager)$/i.test(sanitized.loading)) delete sanitized.loading; + if (tagName === "iframe") { + const embedSrc = typeof sanitized.src === "string" ? normalizeEmbedUrl(sanitized.src) : ""; + if (sanitized.allow !== (embedSrc ? embedAllowPolicy(embedSrc) : "")) delete sanitized.allow; + if (sanitized.sandbox !== embedSandbox) delete sanitized.sandbox; + if (sanitized.referrerpolicy !== embedReferrerPolicy) delete sanitized.referrerpolicy; + delete sanitized.frameborder; + if (sanitized.loading && !/^(lazy|eager)$/i.test(sanitized.loading)) delete sanitized.loading; + } + + return sanitized; +} diff --git a/src/server/security/textToHtml.ts b/src/server/security/textToHtml.ts new file mode 100644 index 0000000..867d7b0 --- /dev/null +++ b/src/server/security/textToHtml.ts @@ -0,0 +1,109 @@ +import sanitizeHtml from "sanitize-html"; +import { normalizeLinkUrl, normalizeTrustedLinkUrl } from "./urls.js"; + +const textTags = ["a", "br"]; +const blogBodyTags = ["a", "b", "blockquote", "br", "code", "em", "h2", "h3", "i", "li", "ol", "p", "small", "span", "strong", "u", "ul"]; + +const textAttributes = { + a: ["href", "title", "target", "rel"] +}; +const urlWithLabelPattern = /\[url=(https?:\/\/[^\]\s<>"']+)\]([\s\S]*?)\[\/url\]/gi; +const urlPattern = /\[url\](https?:\/\/[^\]\s<>"']+)\[\/url\]/gi; +const bareHttpsUrlPattern = /https:\/\/[^\s<>"']+/gi; +const trailingUrlPunctuationPattern = /[),.!?;:\]}]+$/; + +export function sanitizeUserText(input: string) { + return sanitizeTextHtml(input.replace(/\r\n?/g, "\n").replace(/\n/g, "
"), textTags); +} + +export function sanitizeLinkedText(input: string) { + return sanitizeTextHtml(linkBBCodeToHtml(input.replace(/\r\n?/g, "\n")).replace(/\n/g, "
"), textTags); +} + +export function sanitizeBlogBody(input: string) { + return sanitizeTextHtml(blogBBCodeToHtml(input.replace(/\r\n?/g, "\n")).replace(/\n/g, "
"), blogBodyTags); +} + +function sanitizeTextHtml(input: string, allowedTags: string[]) { + return sanitizeHtml(input, { + allowedTags, + allowedAttributes: textAttributes, + allowedSchemes: ["http", "https", "mailto"], + textFilter: (text, tagName) => (tagName === "a" ? text : linkTrustedDomainUrls(text)), + transformTags: { + a: (_tagName, attribs) => { + const sanitized = { ...attribs }; + const href = typeof sanitized.href === "string" ? normalizeLinkUrl(sanitized.href) : ""; + if (href) sanitized.href = href; + else delete sanitized.href; + sanitized.rel = "nofollow noopener noreferrer"; + sanitized.target = "_blank"; + return { tagName: "a", attribs: sanitized }; + } + } + }); +} + +function linkTrustedDomainUrls(text: string) { + return text.replace(bareHttpsUrlPattern, (match) => { + const [urlText, suffix] = splitTrailingUrlPunctuation(match); + const href = normalizeTrustedLinkUrl(decodeTextFilterUrl(urlText)); + if (!href) return match; + return `${displayTextForTrustedUrl(urlText)}${suffix}`; + }); +} + +function displayTextForTrustedUrl(value: string) { + return value.replace(/^https:\/\//i, ""); +} + +function splitTrailingUrlPunctuation(value: string): [urlText: string, suffix: string] { + const match = value.match(trailingUrlPunctuationPattern); + if (!match?.index) return [value, ""]; + return [value.slice(0, match.index), value.slice(match.index)]; +} + +function decodeTextFilterUrl(value: string) { + return value + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/&#(?:39|x27);/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">"); +} + +function escapeHtmlAttribute(value: string) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function blogBBCodeToHtml(input: string) { + return input + .replace(/\[(?:h2|heading)\]([\s\S]*?)\[\/(?:h2|heading)\]/gi, "

$1

") + .replace(/\[(?:h3|subheading)\]([\s\S]*?)\[\/(?:h3|subheading)\]/gi, "

$1

") + .replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "$1") + .replace(/\[i\]([\s\S]*?)\[\/i\]/gi, "$1") + .replace(/\[u\]([\s\S]*?)\[\/u\]/gi, "$1") + .replace(/\[code\]([\s\S]*?)\[\/code\]/gi, "$1") + .replace(/\[quote\]([\s\S]*?)\[\/quote\]/gi, "
$1
") + .replace(/\[(?:list|ul)\]([\s\S]*?)\[\/(?:list|ul)\]/gi, (_match, body: string) => bbcodeListToHtml("ul", body)) + .replace(/\[(?:olist|ol)\]([\s\S]*?)\[\/(?:olist|ol)\]/gi, (_match, body: string) => bbcodeListToHtml("ol", body)) + .replace(/\[img\][\s\S]*?\[\/img\]/gi, "") + .replace(urlWithLabelPattern, '$2') + .replace(urlPattern, '$1'); +} + +function linkBBCodeToHtml(input: string) { + return input + .replace(urlWithLabelPattern, '$2') + .replace(urlPattern, '$1'); +} + +function bbcodeListToHtml(tagName: "ul" | "ol", body: string) { + const markedItems = body.split(/\[\*\]/).map((item) => item.trim()).filter(Boolean); + const items = markedItems.length ? markedItems : body.split(/\n+/).map((item) => item.trim()).filter(Boolean); + return `<${tagName}>${items.map((item) => `
  • ${item.replace(/\n+/g, "
    ")}
  • `).join("")}`; +} diff --git a/src/server/security/urls.ts b/src/server/security/urls.ts new file mode 100644 index 0000000..c23829c --- /dev/null +++ b/src/server/security/urls.ts @@ -0,0 +1,51 @@ +const linkProtocols = new Set(["http:", "https:", "mailto:"]); +const trustedLinkDomains = ["bliish.space", "bliish.com"] as const; + +const trustedLinkDomainSet = new Set(trustedLinkDomains); + +export function normalizeLinkUrl(value: string) { + const trimmed = value.trim().replace(/[\u0000-\u001f\u007f\s]+/g, ""); + if (!trimmed || /^(javascript|vbscript|file|data):/i.test(trimmed)) return ""; + if (trimmed.startsWith("#")) return trimmed; + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed; + + try { + const url = new URL(trimmed); + if (!linkProtocols.has(url.protocol)) return ""; + return url.href; + } catch { + return ""; + } +} + +export function normalizeTrustedLinkUrl(value: string) { + const href = normalizeLinkUrl(value); + if (!href) return ""; + + try { + const url = new URL(href); + if (url.protocol !== "https:") return ""; + if (url.username || url.password || url.port) return ""; + if (!trustedLinkDomainSet.has(url.hostname.toLowerCase())) return ""; + return url.href; + } catch { + return ""; + } +} + +export function normalizeResourceUrl(value: string) { + // User-controlled resources may be local app paths or HTTPS URLs. data: URLs + // stay blocked here even though the global CSP allows data: for app assets. + const trimmed = value.trim().replace(/[\u0000-\u001f\u007f\s]+/g, ""); + if (!trimmed || /^(javascript|vbscript|file|data):/i.test(trimmed)) return ""; + + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed; + + try { + const url = new URL(trimmed); + if (url.protocol !== "https:") return ""; + return url.href; + } catch { + return ""; + } +} diff --git a/src/settings/branding.ts b/src/settings/branding.ts new file mode 100644 index 0000000..6f143e0 --- /dev/null +++ b/src/settings/branding.ts @@ -0,0 +1,7 @@ +import type { ColorPalette } from "../theme/colorPalette.js"; + +export type BrandingSettings = { + customized: boolean; + palette: ColorPalette; + updatedAt: string | null; +}; diff --git a/src/settings/seo.ts b/src/settings/seo.ts new file mode 100644 index 0000000..9805794 --- /dev/null +++ b/src/settings/seo.ts @@ -0,0 +1,134 @@ +import { sourceProject } from "../project.js"; +import { truncateText } from "../text.js"; +import { headerChromeColor, type ColorPalette } from "../theme/colorPalette.js"; +import type { SiteSettings } from "./site.js"; + +export const defaultSocialImagePath = "/og-image.png"; +export const socialImageSize = { + width: 1200, + height: 630 +} as const; + +export type JsonLd = Record; + +export type PageSeo = { + canonicalPath?: string; + description?: string; + imageAlt?: string; + imagePath?: string; + jsonLd?: JsonLd | JsonLd[]; + modifiedTime?: string; + noindex?: boolean; + publishedTime?: string; + title?: string; + type?: "article" | "profile" | "website"; +}; + +export function siteSeoDescription(settings: SiteSettings) { + return seoText(settings.home.welcomeText || siteMarketingDescription(settings), 180); +} + +export function siteMarketingDescription(settings: Pick) { + const name = settings.identity.name.trim() || sourceProject.name; + return `${name} is an ultra-fast, lightweight, open-source social platform with customizable profiles, no ads, no tracking, and simple, affordable self-hosting.`; +} + +export function seoText(input: string, maxLength = 180) { + return truncateText(input.replace(/\s+/g, " ").trim(), maxLength); +} + +export function siteSocialImageAlt(settings: SiteSettings) { + return `${settings.identity.name} social preview`; +} + +export function siteStructuredData(settings: SiteSettings, siteUrl: string, imageUrl: string): JsonLd[] { + const organization = organizationStructuredData(settings, siteUrl); + return [ + { + "@context": "https://schema.org", + "@type": "WebSite", + name: settings.identity.name, + description: siteSeoDescription(settings), + url: siteUrl, + image: imageUrl, + ...(organization ? { publisher: { "@id": organization["@id"] } } : {}) + }, + ...(organization ? [organization] : []) + ]; +} + +export function siteWebManifest(settings: SiteSettings, palette: ColorPalette) { + return JSON.stringify( + { + name: settings.identity.name, + short_name: truncateText(settings.identity.name, 24), + description: siteSeoDescription(settings), + start_url: "/", + scope: "/", + display: "standalone", + background_color: palette.page, + theme_color: headerChromeColor(palette), + icons: [ + { src: "/favicon.svg", sizes: "any", type: "image/svg+xml" }, + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, + { src: "/icon-1024.png", sizes: "1024x1024", type: "image/png" } + ] + }, + null, + 2 + ); +} + +export function siteSocialPreviewSvg(settings: SiteSettings, palette: ColorPalette) { + const background = headerChromeColor(palette); + const foreground = palette.chromeText; + const name = seoText(settings.identity.name || sourceProject.name, 58); + const tagline = seoText(settings.identity.tagline || siteSeoDescription(settings), 86); + const nameSize = fittedFontSize(name, 74, 48, 24); + const taglineSize = fittedFontSize(tagline, 42, 30, 38); + + return [ + ``, + ``, + ``, + socialPreviewIconSvg(settings.identity.headerIconSvg), + "", + `${xmlText(name)}`, + `${xmlText(tagline)}`, + "" + ].join(""); +} + +function organizationStructuredData(settings: SiteSettings, siteUrl: string): JsonLd | null { + const name = settings.contact.companyName.trim(); + if (!name) return null; + return { + "@context": "https://schema.org", + "@id": `${siteUrl}#organization`, + "@type": "Organization", + name, + url: siteUrl, + ...(settings.contact.email ? { email: settings.contact.email } : {}) + }; +} + +function socialPreviewIconSvg(svg: string) { + return svg + .replace(/\swidth="[^"]*"/i, "") + .replace(/\sheight="[^"]*"/i, "") + .replace("/g, ">"); +} + +function xmlAttribute(input: string) { + return xmlText(input).replace(/"/g, """); +} diff --git a/src/settings/site.ts b/src/settings/site.ts new file mode 100644 index 0000000..27002a9 --- /dev/null +++ b/src/settings/site.ts @@ -0,0 +1,110 @@ +import { brandIconShapeSvg, brandIconSvg } from "../brand.js"; +import { colorPaletteTokens, defaultColorPalette, mixColor, type ColorPalette } from "../theme/colorPalette.js"; + +export type SiteIdentitySettings = { + name: string; + tagline: string; + headerIconName: string; + headerIconSvg: string; +}; + +export type SiteHomeSettings = { + announcement: string; + welcomeText: string; +}; + +export type SiteContactSettings = { + email: string; + companyName: string; + mailingAddress: string; +}; + +export type SiteSettings = { + identity: SiteIdentitySettings; + home: SiteHomeSettings; + contact: SiteContactSettings; + updatedAt: string | null; +}; + +export const defaultHeaderIconName = "brand"; + +export const defaultHeaderIconSvg = brandIconSvg; + +export const defaultSiteSettings = { + identity: { + name: "bliish.space", + tagline: "a space for anyone", + headerIconName: defaultHeaderIconName, + headerIconSvg: defaultHeaderIconSvg + }, + home: { + announcement: "[url=https://bliish.space]Bliish.space[/url] is an open-source project by [url=https://bliish.com]Bliish.com[/url], built for small, customizable, self-hosted social communities.", + welcomeText: "Bliish.space is an ultra-fast, lightweight, open-source social platform with customizable profiles, no ads, no tracking, and simple, affordable self-hosting." + }, + contact: { + email: "hi@bliish.com", + companyName: "Bliish LLC", + mailingAddress: "" + }, + updatedAt: null +} satisfies SiteSettings; + +const defaultFaviconSvg = [ + '" +].join(""); +const faviconIconSize = 18; +const faviconIconOffset = (24 - faviconIconSize) / 2; +const faviconStrokeWidth = 3.4; +const appIconSize = 1024; +const appIconGlyphSize = 768; +const appIconGlyphOffset = (appIconSize - appIconGlyphSize) / 2; + +export function siteFaviconSvg(settings: SiteSettings, palette: ColorPalette = defaultColorPalette) { + if (settings.identity.headerIconName === defaultHeaderIconName && isDefaultPalette(palette)) return defaultFaviconSvg; + const background = mixColor(palette.chrome, "#000000", 0.28); + const iconColor = palette.chromeText; + return [ + '" + ].join(""); +} + +export function siteAppIconSvg(settings: SiteSettings, palette: ColorPalette = defaultColorPalette) { + const background = mixColor(palette.chrome, "#000000", 0.28); + const iconColor = palette.chromeText; + return [ + `" + ].join(""); +} + +function faviconIconSvg(svg: string) { + const sizedSvg = identityIconSvg(svg, faviconIconOffset, faviconIconOffset, faviconIconSize, faviconIconSize); + return sizedSvg.includes("stroke-width=") + ? sizedSvg.replace(/\sstroke-width="[^"]*"/i, ` stroke-width="${faviconStrokeWidth}"`) + : sizedSvg; +} + +function identityIconSvg(svg: string, x: number, y: number, width: number, height: number) { + return svg + .replace(/\swidth="[^"]*"/i, "") + .replace(/\sheight="[^"]*"/i, "") + .replace(" palette[token] === defaultColorPalette[token]); +} diff --git a/src/shell/footer.tsx b/src/shell/footer.tsx new file mode 100644 index 0000000..aeb4048 --- /dev/null +++ b/src/shell/footer.tsx @@ -0,0 +1,66 @@ +import { sourceProject } from "../project.js"; +import type { SiteContactSettings } from "../settings/site.js"; +import { Icon, type IconName } from "../ui/icons.js"; +import type { DataAttributes } from "../ui/types.js"; + +const footerSocialLinks: readonly { href: string; icon: IconName; label: string }[] = [ + { href: "https://www.reddit.com/r/bliish/", icon: "reddit", label: "Reddit" }, + { href: "https://github.com/bliish-com", icon: "github", label: "GitHub" } +]; + +export function Footer({ contact, dataAttributes }: { contact: SiteContactSettings; dataAttributes?: DataAttributes }) { + const copyrightYears = copyrightYearLabel(sourceProject.creationYear); + + return ( + + ); +} + +function copyrightYearLabel(creationYear: number) { + const currentYear = new Date().getFullYear(); + return currentYear > creationYear ? `${creationYear}-${currentYear}` : `${creationYear}`; +} diff --git a/src/shell/index.ts b/src/shell/index.ts new file mode 100644 index 0000000..f2e7b4c --- /dev/null +++ b/src/shell/index.ts @@ -0,0 +1,4 @@ +export { Layout, PlainPage } from "./layout.js"; +export { PageFrame, PageHeading, SplitLayout, SplitPane } from "./page.js"; +export type { PageFrameWidth, SplitLayoutVariant, SplitPaneArea } from "./page.js"; +export type { PageSeo } from "../settings/seo.js"; diff --git a/src/shell/layout.tsx b/src/shell/layout.tsx new file mode 100644 index 0000000..6d69f4a --- /dev/null +++ b/src/shell/layout.tsx @@ -0,0 +1,148 @@ +import { raw } from "hono/html"; +import type { BrandingSettings } from "../settings/branding.js"; +import { defaultSiteSettings } from "../settings/site.js"; +import { brandingSettings } from "../server/db/branding.js"; +import { raidModeActive } from "../server/db/rateLimits.js"; +import { siteSettings } from "../server/db/siteSettings.js"; +import type { CurrentUser } from "../currentUser.js"; +import type { PageSeo } from "../settings/seo.js"; +import type { ProfileSkinPart } from "../skins/contract.js"; +import { defaultColorPalette } from "../theme/colorPalette.js"; +import { Icon } from "../ui/icons.js"; +import { TimeZoneProvider } from "../ui/time.js"; +import type { DataAttributes, ViewChild } from "../ui/types.js"; +import { Footer } from "./footer.js"; +import { Nav } from "./nav.js"; +import { PageFrame } from "./page.js"; +import { SeoHead } from "./seo.js"; + +export function Layout(props: { + title: string; + user: CurrentUser | null; + bodyAttributes?: DataAttributes; + browserThemeColor?: (branding: BrandingSettings) => string; + head?: ViewChild; + seo?: PageSeo; + styles?: readonly string[]; + children: ViewChild; +}) { + const settings = safeSiteSettings(); + const branding = safeBrandingSettings(); + const themeLocked = themeLockedFor(props.bodyAttributes, branding.customized); + const skinActive = skinActiveFor(props.bodyAttributes); + const htmlAttributes = themeLocked ? { "data-theme-lock": "light" } satisfies DataAttributes : undefined; + const skinPart = skinPartAttributes(skinActive); + const title = documentTitle(props.title, settings.identity.name); + const browserThemeColor = props.browserThemeColor?.(branding); + return ( + <> + {raw("")} + + + + + + + + + + + + + + + + {props.styles?.map((href) => )} + {props.head} + + +
    +
    + + +
    + + ); +} + +export function documentTitle(title: string, siteName: string) { + const pageTitle = title.trim(); + const name = siteName.trim(); + if (!pageTitle) return name; + if (!name || titleIncludesSiteName(pageTitle, name)) return pageTitle; + return `${name} | ${pageTitle}`; +} + +function titleIncludesSiteName(title: string, siteName: string) { + return title === siteName || title.endsWith(` ${siteName}`) || title.endsWith(` | ${siteName}`) || title.startsWith(`${siteName} |`); +} + +function safeSiteSettings() { + try { + return siteSettings(); + } catch { + return defaultSiteSettings; + } +} + +function themeLockedFor(bodyAttributes: DataAttributes | undefined, brandingCustomized: boolean) { + return Boolean(bodyAttributes && "data-skin-page" in bodyAttributes) || brandingCustomized; +} + +function skinActiveFor(bodyAttributes?: DataAttributes) { + return Boolean(bodyAttributes && "data-skin-page" in bodyAttributes); +} + +function skinPartAttributes(active: boolean) { + return (part: ProfileSkinPart): DataAttributes | undefined => active ? { "data-skin-part": part } : undefined; +} + +function safeBrandingSettings(): BrandingSettings { + try { + return brandingSettings(); + } catch { + return { customized: false, palette: defaultColorPalette, updatedAt: null }; + } +} + +function RaidModeBanner() { + if (!safeRaidModeActive()) return null; + return ( + + ); +} + +function safeRaidModeActive() { + try { + return raidModeActive(); + } catch { + return false; + } +} + +export function PlainPage(props: { user: CurrentUser | null; title: string; body: ViewChild; seo?: PageSeo }) { + const body = typeof props.body === "string" || typeof props.body === "number" ?

    {props.body}

    : props.body; + return ( + + + {body} + + + ); +} diff --git a/src/shell/nav.tsx b/src/shell/nav.tsx new file mode 100644 index 0000000..d1b2d80 --- /dev/null +++ b/src/shell/nav.tsx @@ -0,0 +1,152 @@ +import type { SiteIdentitySettings } from "../settings/site.js"; +import { mainNavPageOrder, pageLinks } from "../navigation.js"; +import { notificationsPath } from "../paths.js"; +import { hasPermission, type Permission } from "../roles.js"; +import { unreadMessageCount } from "../server/db/messages/index.js"; +import { unreadNotificationCount } from "../server/db/notifications/index.js"; +import type { CurrentUser } from "../currentUser.js"; +import type { ProfileSkinPart } from "../skins/contract.js"; +import { ActionLabel } from "../ui/actions.js"; +import { CountBadge } from "../ui/badges.js"; +import { Icon, SvgIcon } from "../ui/icons.js"; +import { InlineLinks, type LinkItem } from "../ui/links.js"; +import type { DataAttributes } from "../ui/types.js"; + +type StaffMenuPage = { + readonly label: string; + readonly href: string; + readonly permission: Permission; +}; + +const staffMenuPages: readonly StaffMenuPage[] = [ + { label: "Admin", href: "/admin", permission: "admin" }, + { label: "Moderation", href: "/moderation", permission: "moderateReports" } +]; + +export function Nav({ hideThemeToggle, identity, skinActive, user }: { hideThemeToggle?: boolean; identity: SiteIdentitySettings; skinActive?: boolean; user: CurrentUser | null }) { + const staffLink = user ? staffMenuLinkFor(user) : null; + const messageCount = user ? safeUnreadMessageCount(user.id) : 0; + const notificationCount = user ? safeUnreadNotificationCount(user.id) : 0; + const utilityLinks = [ + ...(user ? [, ] : []), + ...(hideThemeToggle ? [] : []), + + ]; + const skinPart = skinPartAttributes(Boolean(skinActive)); + const navItems = mainNavPageOrder.map((key) => { + const page = pageLinks[key]; + return { href: key === "home" && user ? "/home" : page.href, key, label: page.label }; + }); + const accountLinks: LinkItem[] = user + ? [...(staffLink ? [staffLink] : []), ["Help", "/help"], ["Log out", "/logout"]] + : [["Help", "/help"], ["Log in", "/login"], ["Sign up", "/signup"]]; + + return ( +
    + +
    + ); +} + +function skinPartAttributes(active: boolean) { + return (part: ProfileSkinPart): DataAttributes | undefined => active ? { "data-skin-part": part } : undefined; +} + +function staffMenuLinkFor(user: CurrentUser): LinkItem | null { + const page = staffMenuPages.find((item) => hasPermission(user, item.permission)); + return page ? [page.label, page.href] : null; +} + +function RefreshLink() { + return ( + + + + ); +} + +function ThemeToggle() { + return ( + + + + + + + + + ); +} + +function NotificationsLink({ count }: { count: number }) { + return ( + + + {count ? : null} + + ); +} + +function SettingsLink() { + return ( + + + + ); +} + +function safeUnreadNotificationCount(userId: number) { + try { + return unreadNotificationCount(userId); + } catch { + return 0; + } +} + +function safeUnreadMessageCount(userId: number) { + try { + return unreadMessageCount(userId); + } catch { + return 0; + } +} diff --git a/src/shell/page.tsx b/src/shell/page.tsx new file mode 100644 index 0000000..11605db --- /dev/null +++ b/src/shell/page.tsx @@ -0,0 +1,77 @@ +import { classNames } from "../ui/classes.js"; +import type { DataAttributes, ViewChild } from "../ui/types.js"; + +type HeadingLevel = "h1" | "h2" | "h3" | "h4"; + +export type PageFrameWidth = "narrow" | "wide" | "full"; +export type SplitLayoutVariant = "landing" | "dashboard" | "profile" | "article" | "editor" | "messages"; +export type SplitPaneArea = "main" | "aside" | "sidebar"; + +export function PageFrame(props: { + actions?: ViewChild; + back?: ViewChild; + children?: ViewChild; + className?: string; + title?: ViewChild; + titleLevel?: HeadingLevel; + width?: PageFrameWidth; +}) { + const width = props.width ?? "narrow"; + return ( +
    + {props.back} + {props.title ? {props.title} : null} + {props.children} +
    + ); +} + +export function PageHeading(props: { actions?: ViewChild; children: ViewChild; className?: string; level?: HeadingLevel }) { + return ( +
    + {props.children} + {props.actions ?
    {props.actions}
    : null} +
    + ); +} + +function Heading(props: { children: ViewChild; className?: string; level: HeadingLevel }) { + switch (props.level) { + case "h1": + return

    {props.children}

    ; + case "h2": + return

    {props.children}

    ; + case "h3": + return

    {props.children}

    ; + case "h4": + return

    {props.children}

    ; + } +} + +export function SplitLayout(props: { + children: ViewChild; + className?: string; + dataAttributes?: DataAttributes; + itemscope?: boolean; + itemtype?: string; + variant: SplitLayoutVariant; +}) { + return ( +
    + {props.children} +
    + ); +} + +export function SplitPane(props: { area: SplitPaneArea; children: ViewChild; className?: string; dataAttributes?: DataAttributes }) { + return ( +
    + {props.children} +
    + ); +} diff --git a/src/shell/seo.tsx b/src/shell/seo.tsx new file mode 100644 index 0000000..1486af2 --- /dev/null +++ b/src/shell/seo.tsx @@ -0,0 +1,74 @@ +import { raw } from "hono/html"; +import type { BrandingSettings } from "../settings/branding.js"; +import { + defaultSocialImagePath, + siteSeoDescription, + siteSocialImageAlt, + siteStructuredData, + socialImageSize, + type JsonLd, + type PageSeo +} from "../settings/seo.js"; +import type { SiteSettings } from "../settings/site.js"; +import { headerChromeColor } from "../theme/colorPalette.js"; +import { noindexHeader } from "../server/indexing/routes.js"; +import { absoluteUrl } from "../server/indexing/urls.js"; + +export function SeoHead(props: { + branding: BrandingSettings; + documentTitle: string; + pageTitle: string; + seo?: PageSeo; + settings: SiteSettings; + themeColor?: string; +}) { + const description = props.seo?.description || siteSeoDescription(props.settings); + const imagePath = props.seo?.imagePath || defaultSocialImagePath; + const imageUrl = absoluteUrl(imagePath); + const canonicalUrl = props.seo?.canonicalPath ? absoluteUrl(props.seo.canonicalPath) : null; + const title = props.seo?.title || props.documentTitle; + const imageAlt = props.seo?.imageAlt || siteSocialImageAlt(props.settings); + const themeColor = props.themeColor || headerChromeColor(props.branding.palette); + const jsonLd = [ + ...siteStructuredData(props.settings, absoluteUrl("/"), absoluteUrl(defaultSocialImagePath)), + ...jsonLdArray(props.seo?.jsonLd) + ]; + + return ( + <> + {props.documentTitle} + + + {props.seo?.noindex ? : null} + {canonicalUrl ? : null} + + + + + {canonicalUrl ? : null} + + + + + {props.seo?.publishedTime ? : null} + {props.seo?.modifiedTime ? : null} + + + + + + {jsonLd.map((item) => ( + + ))} + + ); +} + +function jsonLdArray(jsonLd: PageSeo["jsonLd"] | undefined): JsonLd[] { + if (!jsonLd) return []; + return Array.isArray(jsonLd) ? jsonLd : [jsonLd]; +} + +function jsonLdScriptContent(jsonLd: JsonLd) { + return JSON.stringify(jsonLd).replace(/(builtinSkinDefinitions.map((skin) => skin.sourceKey)); + +export function builtinSkinAttribution(sourceKey: string | null | undefined) { + return sourceKey && builtinSkinSourceKeys.has(sourceKey) ? builtinSkinAuthor : null; +} + +export function builtinSkinOrderSql(column = "source_key") { + const cases = builtinSkinDefinitions + .map((skin, index) => `WHEN '${sqlString(skin.sourceKey)}' THEN ${index}`) + .join(" "); + return `CASE ${column} ${cases} ELSE ${builtinSkinDefinitions.length} END`; +} + +function skinCodeFromTheme(theme: BuiltinSkinTheme) { + const variables = { + "skin-palette-accent": theme.accent, + "skin-palette-accent-text": theme.accentText, + "skin-palette-backdrop": theme.backdrop, + "skin-palette-chrome": theme.chrome, + "skin-palette-chrome-text": theme.chromeText, + "skin-palette-link": theme.link, + "skin-palette-link-hover": theme.linkHover, + "skin-palette-muted": theme.muted, + "skin-palette-page": theme.page, + "skin-palette-page-text": theme.pageText, + "skin-palette-surface": theme.surface, + "skin-palette-surface-link-hover": theme.surfaceLinkHover, + "skin-palette-surface-text": theme.surfaceText + }; + return [ + "" + ].join("\n"); +} + +function exampleSkinDefinition(skin: (typeof builtinExampleSkins)[number]): BuiltinSkinDefinition { + return { + sourceKey: skin.sourceKey, + title: skin.title, + descriptionHtml: originalCreditDescription(skin.creditName, skin.creditUrl), + codeHtml: readExampleSkinCode(skin.file) + }; +} + +function originalCreditDescription(name: string, url: string) { + return [ + "

    Original SpaceHey layout credit:", + `
    ${escapeHtml(name)}`, + `
    ${escapeHtml(url)}`, + "

    " + ].join(""); +} + +function readExampleSkinCode(file: string) { + const html = readFileSync(new URL(`../../docs/skin-examples/${file}`, import.meta.url), "utf8"); + return html.replace(/^\s*/, "").trim(); +} + +function escapeHtml(value: string) { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function sqlString(value: string) { + return value.replace(/'/g, "''"); +} diff --git a/src/skins/colorPalette.ts b/src/skins/colorPalette.ts new file mode 100644 index 0000000..f139260 --- /dev/null +++ b/src/skins/colorPalette.ts @@ -0,0 +1,66 @@ +import { + colorPaletteFromCssVariables, + colorPaletteFromForm, + cssCustomPropertyDeclarations, + deriveColorPalette, + headerChromeColor, + type ColorPalette, + type ColorPaletteToken +} from "../theme/colorPalette.js"; +import { stringFromUnknown } from "../values.js"; + +export const skinColorIntentField = "intent"; +export const skinColorGenerateIntent = "generate-skin"; + +const skinColorPaletteAliases = { + accent: ["skin-palette-accent", "skin-accent"], + accentText: ["skin-palette-accent-text", "skin-accent-text"], + chrome: ["skin-palette-chrome", "skin-panel-heading-background"], + chromeText: ["skin-palette-chrome-text", "skin-panel-heading-text"], + link: ["skin-palette-link", "skin-link"], + backdrop: ["skin-palette-backdrop", "skin-backdrop"], + page: ["skin-palette-page", "skin-background"], + surface: ["skin-palette-surface", "skin-panel-background"] +} satisfies Record; + +export function isSkinColorGenerateIntent(form: Record) { + return stringFromUnknown(form[skinColorIntentField]) === skinColorGenerateIntent; +} + +export function skinColorPaletteFromHtml(code = "", fallback?: ColorPalette) { + return colorPaletteFromCssVariables(code, skinColorPaletteAliases, fallback); +} + +export function skinBrowserThemeColorFromHtml(code = "", fallback?: ColorPalette) { + return headerChromeColor(skinColorPaletteFromHtml(code, fallback)); +} + +export function skinStyleCodeFromColorForm(form: Record, fallback?: ColorPalette) { + return skinStyleCodeFromColorPalette(colorPaletteFromForm(form, fallback)); +} + +function skinStyleCodeFromColorPalette(source: ColorPalette) { + const colors = deriveColorPalette(source); + const variables = { + "skin-palette-accent": source.accent, + "skin-palette-accent-text": source.accentText, + "skin-palette-chrome": source.chrome, + "skin-palette-chrome-text": source.chromeText, + "skin-palette-link": source.link, + "skin-palette-link-hover": colors.pageLinkHover, + "skin-palette-muted": colors.muted, + "skin-palette-backdrop": source.backdrop, + "skin-palette-page": source.page, + "skin-palette-page-text": colors.pageText, + "skin-palette-surface": source.surface, + "skin-palette-surface-link-hover": colors.surfaceLinkHover, + "skin-palette-surface-text": colors.surfaceText + }; + return [ + "" + ].join("\n"); +} diff --git a/src/skins/colorPaletteEditor.tsx b/src/skins/colorPaletteEditor.tsx new file mode 100644 index 0000000..a142ca5 --- /dev/null +++ b/src/skins/colorPaletteEditor.tsx @@ -0,0 +1,24 @@ +import { + skinColorGenerateIntent, + skinColorIntentField, + skinColorPaletteFromHtml +} from "./colorPalette.js"; +import { ColorSwatches, colorSwatchesStylesheet } from "../ui/colorSwatches.js"; +import { FormActions } from "../ui/forms.js"; +import type { ColorPalette } from "../theme/colorPalette.js"; + +export { colorSwatchesStylesheet as skinColorPaletteEditorStylesheet }; + +export function SkinColorPaletteEditor({ codeHtml, fallback }: { codeHtml?: string; fallback?: ColorPalette }) { + const palette = skinColorPaletteFromHtml(codeHtml, fallback); + return ( +
    + + + + +
    + ); +} diff --git a/src/skins/contract.ts b/src/skins/contract.ts new file mode 100644 index 0000000..8c630c9 --- /dev/null +++ b/src/skins/contract.ts @@ -0,0 +1,41 @@ +export const profileSkinVersion = "2026"; + +const profileSkinParts = [ + "about", + "account", + "actions", + "bio", + "bio-content", + "blog-preview", + "brand", + "comment", + "content", + "custom-html", + "details", + "footer", + "friends", + "header", + "identity", + "interests", + "links", + "main", + "name", + "navigation", + "navigation-links", + "navigation-top", + "notice", + "page", + "photo", + "post", + "sidebar", + "search", + "shell", + "theme-song", + "url", + "vibe", + "wall" +] as const; + +export type ProfileSkinPart = (typeof profileSkinParts)[number]; + +export const profileSkinPartSet = new Set(profileSkinParts); diff --git a/src/skins/docs.tsx b/src/skins/docs.tsx new file mode 100644 index 0000000..693022f --- /dev/null +++ b/src/skins/docs.tsx @@ -0,0 +1,19 @@ +const profileSkinDocsUrl = "https://github.com/bliish-com/bliishspace/blob/main/docs/skins.md"; +const profileSkinsUrl = "/skins"; +const profileSkinHtmlHintText = "Use supported skin variables and data-skin selectors."; + +export function ProfileSkinDocsLink() { + return Skin docs; +} + +export function ProfileSkinsPageLink() { + return View more skins; +} + +export function ProfileSkinHtmlHint() { + return ( + <> + {profileSkinHtmlHintText} + + ); +} diff --git a/src/skins/rendering.tsx b/src/skins/rendering.tsx new file mode 100644 index 0000000..eaee7dc --- /dev/null +++ b/src/skins/rendering.tsx @@ -0,0 +1,734 @@ +import { createHash } from "node:crypto"; +import { raw } from "hono/html"; +import type { Child } from "hono/jsx"; +import { sanitizeSkinHtml } from "../server/security/html.js"; +import { trustedHtml } from "../ui/html.js"; +import type { DataAttributes } from "../ui/types.js"; +import { profileSkinVersion, type ProfileSkinPart } from "./contract.js"; + +const leadingCssImportPattern = /^@import\s+(?:url\(\s*(?:"[^"]+"|'[^']+'|[^)\s]+)\s*\)|"[^"]+"|'[^']+'|[^;\s]+)[^;]*;\s*/i; +const styleBlockPattern = /`; + }); +} + +export function AuthorSkinBoundary(props: { + backdrop?: AuthorSkinBackdropMode; + children: Child; + contextParts?: readonly AuthorSkinContextPart[]; + skinHtml?: string | null; +}) { + const scope = authorSkinScopeFromHtml(props.skinHtml); + if (!scope) return <>{props.children}; + const backdrop = props.backdrop ?? "none"; + + return ( +
    +
    +
    +
    +
    +
    + {backdrop === "container" + ? authorSkinProfileWallFrame(props.children) + : ( +
    +
    +
    + {authorSkinContextContainers(props.contextParts ?? [], props.children)} +
    +
    +
    + )} +
    +
    + ); +} + +function authorSkinStyleHtmlForItems(items: readonly AuthorSkinItem[]) { + const scopes = new Map(); + for (const item of items) { + const scope = authorSkinScopeFromHtml(item.authorSkinHtml); + if (scope && !scopes.has(scope.id)) scopes.set(scope.id, scope); + } + return [...scopes.values()].map((scope) => layerScopedProfileSkinStyleHtml(scope.skin.styleHtml, scope.id)).join("\n"); +} + +function layerScopedProfileSkinStyleHtml(styleHtml: string, scopeId: string) { + const cssScope = authorSkinCssScope(styleHtml, scopeId); + const reset = ``; + return reset + styleHtml.replace(styleBlockPattern, (_match, css: string) => { + const { imports, rules } = splitLeadingCssImports(css); + const scopedRules = rules ? transformScopedCssBlock(rules, scopeId, cssScope) : ""; + return ``; + }); +} + +function splitLeadingCssImports(css: string) { + const imports: string[] = []; + let rules = css.trim(); + let match = rules.match(leadingCssImportPattern); + + while (match) { + imports.push(match[0].trim()); + rules = rules.slice(match[0].length).trim(); + match = rules.match(leadingCssImportPattern); + } + + return { imports, rules }; +} + +function authorSkinScopeFromHtml(html: string | null | undefined): AuthorSkinScope | null { + const skin = profileSkinFromHtml(html ?? ""); + if (!skin.styleHtml.trim()) return null; + return { + id: createHash("sha256").update(skin.styleHtml).digest("hex").slice(0, 12), + skin + }; +} + +function authorSkinContextContainers(parts: readonly AuthorSkinContextPart[], children: Child): Child { + return parts.reduceRight( + (child, part) =>
    {child}
    , + children + ); +} + +function authorSkinProfileWallFrame(children: Child) { + return ( +
    +
    +
    +
    +
    +
    + {children} +
    +
    +
    +
    +
    +
    + ); +} + +function authorSkinCssScope(styleHtml: string, scopeId: string): AuthorSkinCssScope { + return { + customProperties: collectCustomPropertiesFromStyleHtml(styleHtml, scopeId), + keyframes: collectKeyframeNamesFromStyleHtml(styleHtml, scopeId) + }; +} + +function collectCustomPropertiesFromStyleHtml(styleHtml: string, scopeId: string) { + const customProperties = new Map(); + for (const css of styleBlockCsses(styleHtml)) { + const { rules } = splitLeadingCssImports(css); + collectCustomProperties(rules, scopeId, customProperties); + } + return customProperties; +} + +function collectCustomProperties(css: string, scopeId: string, customProperties: Map) { + walkCssBlocks(css, (prelude, body) => { + if (prelude.toLowerCase().startsWith("@media")) { + collectCustomProperties(body, scopeId, customProperties); + return; + } + + if (prelude.startsWith("@")) return; + + for (const declaration of splitDeclarations(body)) { + const colon = declaration.indexOf(":"); + if (colon === -1) continue; + + const property = declaration.slice(0, colon).trim(); + if (property.startsWith("--") && !publicSkinCustomProperties.has(property) && !customProperties.has(property)) { + customProperties.set(property, scopedCustomPropertyName(scopeId, property)); + } + } + }); +} + +function collectKeyframeNamesFromStyleHtml(styleHtml: string, scopeId: string) { + const keyframes = new Map(); + for (const css of styleBlockCsses(styleHtml)) { + const { rules } = splitLeadingCssImports(css); + collectKeyframeNames(rules, scopeId, keyframes); + } + return keyframes; +} + +function styleBlockCsses(styleHtml: string) { + return [...styleHtml.matchAll(styleBlockPattern)].map((match) => match[1] ?? ""); +} + +function authorSkinLayerBaselineCss(scopeId: string) { + const scope = `[data-author-skin-scope="${scopeId}"]`; + return `${scope},${scope} *{all:revert-layer;}`; +} + +function collectKeyframeNames(css: string, scopeId: string, keyframes = new Map()) { + walkCssBlocks(css, (prelude, body) => { + const keyframeName = keyframeNameFromPrelude(prelude); + if (keyframeName) keyframes.set(keyframeName, scopedKeyframeName(scopeId, keyframeName)); + else if (prelude.toLowerCase().startsWith("@media")) collectKeyframeNames(body, scopeId, keyframes); + }); + return keyframes; +} + +function transformScopedCssBlock(css: string, scopeId: string, cssScope: AuthorSkinCssScope) { + let output = ""; + walkCssBlocks(css, (prelude, body) => { + const keyframeName = keyframeNameFromPrelude(prelude); + if (keyframeName) { + output += `@keyframes ${cssScope.keyframes.get(keyframeName) ?? keyframeName}{${body}}`; + return; + } + + if (prelude.toLowerCase().startsWith("@media")) { + const nested = transformScopedCssBlock(body, scopeId, cssScope); + if (nested) output += `${prelude}{${nested}}`; + return; + } + + const scopedSelectors = scopedAuthorSkinSelectors(prelude, scopeId); + const scopedBody = rewriteScopedDeclarations(body, cssScope); + if (scopedSelectors.full.length && scopedBody) output += `${scopedSelectors.full.join(",")}{${scopedBody}}`; + + const scopedLayerPaint = rewriteScopedLayerPaintDeclarations(body, cssScope); + if (scopedSelectors.backdrop.length && scopedLayerPaint) output += `${scopedSelectors.backdrop.join(",")}{${scopedLayerPaint}}`; + if (scopedSelectors.content.length && scopedLayerPaint) output += `${scopedSelectors.content.join(",")}{${scopedLayerPaint}}`; + if (scopedSelectors.root.length && scopedLayerPaint) output += `${scopedSelectors.root.join(",")}{${scopedLayerPaint}}`; + if (scopedSelectors.shell.length && scopedLayerPaint) output += `${scopedSelectors.shell.join(",")}{${scopedLayerPaint}}`; + + const scopedContextDeclarations = rewriteScopedContextDeclarations(body, cssScope); + if (scopedSelectors.context.length && scopedContextDeclarations) { + output += `${scopedSelectors.context.join(",")}{${scopedContextDeclarations}}`; + } + }); + return output; +} + +function scopedAuthorSkinSelectors(prelude: string, scopeId: string) { + const selectors = { + backdrop: [] as string[], + content: [] as string[], + context: [] as string[], + full: [] as string[], + root: [] as string[], + shell: [] as string[] + }; + + for (const selector of prelude.split(",")) { + const normalized = selector.trim(); + if (!normalized) continue; + + const authorSelector = authorSkinSelector(normalized); + const scopedSelector = scopeAuthorSkinSelector(authorSelector, scopeId); + if (isAuthorSkinContextOnlySelector(authorSelector)) { + selectors.context.push(scopedSelector); + if (isAuthorSkinPageBackgroundSelector(authorSelector)) selectors.backdrop.push(authorSkinLayerSelector(scopeId, "backdrop")); + if (isAuthorSkinShellBackgroundSelector(authorSelector)) selectors.shell.push(authorSkinLayerSelector(scopeId, "shell")); + if (isAuthorSkinContentBackgroundSelector(authorSelector)) selectors.content.push(authorSkinLayerSelector(scopeId, "content")); + if (isAuthorSkinRootBackgroundSelector(authorSelector)) selectors.root.push(authorSkinLayerSelector(scopeId, "root")); + } else { + selectors.full.push(scopedSelector); + selectors.full.push(...authorSkinCommentFallbackSelectors(authorSelector, scopeId)); + } + } + + return selectors; +} + +function authorSkinCommentFallbackSelectors(selector: string, scopeId: string) { + if (selector.includes(authorSkinCommentPartSelector)) return []; + + const fallbacks = new Set(); + if (selector.includes(authorSkinPostPartSelector)) { + fallbacks.add(selector.split(authorSkinPostPartSelector).join(authorSkinCommentPartSelector)); + } + + const postCardFallback = selector.replace(postCardClassSelectorPattern, `$1${authorSkinCommentPartSelector}`); + if (postCardFallback !== selector) fallbacks.add(postCardFallback); + + return [...fallbacks].map((fallback) => scopeAuthorSkinFallbackSelector(fallback, scopeId)); +} + +function scopeAuthorSkinFallbackSelector(selector: string, scopeId: string) { + return `[data-author-skin-scope="${scopeId}"] :where(${selector})`; +} + +function authorSkinLayerSelector(scopeId: string, layer: "backdrop" | "content" | "root" | "shell") { + return `[data-author-skin-scope="${scopeId}"] > [data-author-skin-layer="${layer}"]`; +} + +function scopeAuthorSkinSelector(selector: string, scopeId: string) { + const scopeSelector = `[data-author-skin-scope="${scopeId}"]`; + if ( + selector.startsWith("[data-author-skin-page]") || + selector.startsWith('[data-author-skin-part="page"]') || + selector.startsWith(`[data-author-skin-version="${profileSkinVersion}"]`) + ) { + return `${scopeSelector}${selector}`; + } + return `${scopeSelector} ${selector}`; +} + +function isAuthorSkinContextOnlySelector(selector: string) { + return authorSkinContextOnlySelectorPattern.test(selector); +} + +function isAuthorSkinPageBackgroundSelector(selector: string) { + return ( + selector === "[data-author-skin-page]" || + selector === '[data-author-skin-part="page"]' || + selector === `[data-author-skin-version="${profileSkinVersion}"]` + ); +} + +function isAuthorSkinShellBackgroundSelector(selector: string) { + return selector === '[data-author-skin-part="shell"]'; +} + +function isAuthorSkinContentBackgroundSelector(selector: string) { + return selector === '[data-author-skin-part="content"]'; +} + +function isAuthorSkinRootBackgroundSelector(selector: string) { + return selector === "[data-author-skin-root]"; +} + +function authorSkinSelector(selector: string) { + return selector + .replace(/\[data-skin-page\]/g, "[data-author-skin-page]") + .replace(/\[data-skin-root\]/g, "[data-author-skin-root]") + .replace(skinVersionSelectorPattern, `[data-author-skin-version="${profileSkinVersion}"]`) + .replace(skinPartSelectorPattern, (_match, doubleQuoted: string, singleQuoted: string, unquoted: string) => { + const part = doubleQuoted || singleQuoted || unquoted || ""; + return `[data-author-skin-part="${part}"]`; + }); +} + +function walkCssBlocks(css: string, visit: (prelude: string, body: string) => void) { + let index = 0; + + while (index < css.length) { + const open = css.indexOf("{", index); + if (open === -1) break; + + const prelude = css.slice(index, open).trim(); + const close = matchingBrace(css, open); + if (close === -1) break; + + visit(prelude, css.slice(open + 1, close)); + index = close + 1; + } +} + +function keyframeNameFromPrelude(prelude: string) { + const match = prelude.match(/^@keyframes\s+([-_a-zA-Z0-9]+)$/i); + return match?.[1] ?? null; +} + +function scopedKeyframeName(scopeId: string, keyframeName: string) { + return `author-skin-${scopeId}-${keyframeName}`; +} + +function scopedCustomPropertyName(scopeId: string, property: string) { + return `--author-skin-${scopeId}-${property.slice(2)}`; +} + +function rewriteScopedDeclarations(body: string, cssScope: AuthorSkinCssScope) { + return splitDeclarations(body) + .map((declaration) => { + const colon = declaration.indexOf(":"); + if (colon === -1) return declaration; + + const property = declaration.slice(0, colon).trim(); + const scopedProperty = cssScope.customProperties.get(property) ?? property; + let value = replaceCustomPropertyTokens(declaration.slice(colon + 1), cssScope.customProperties); + if (property === "animation" || property === "animation-name") { + value = replaceKeyframeTokens(value, cssScope.keyframes); + } + return `${scopedProperty}:${value};`; + }) + .join(""); +} + +const contextDeclarationProperties = new Set([ + "accent-color", + "caret-color", + "color", + "cursor", + "font", + "font-family", + "font-feature-settings", + "font-kerning", + "font-optical-sizing", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-synthesis", + "font-variant", + "font-variant-caps", + "font-variant-east-asian", + "font-variant-ligatures", + "font-variant-numeric", + "font-variation-settings", + "font-weight", + "hyphens", + "letter-spacing", + "line-break", + "line-height", + "list-style", + "list-style-image", + "list-style-position", + "list-style-type", + "overflow-wrap", + "quotes", + "tab-size", + "text-align", + "text-align-last", + "text-indent", + "text-justify", + "text-shadow", + "text-transform", + "text-wrap", + "white-space", + "word-break", + "word-spacing" +]); + +function rewriteScopedContextDeclarations(body: string, cssScope: AuthorSkinCssScope) { + return splitDeclarations(body) + .map((declaration) => { + const colon = declaration.indexOf(":"); + if (colon === -1) return ""; + + const property = declaration.slice(0, colon).trim(); + if (!property.startsWith("--") && !contextDeclarationProperties.has(property)) return ""; + + const scopedProperty = cssScope.customProperties.get(property) ?? property; + const value = replaceCustomPropertyTokens(declaration.slice(colon + 1), cssScope.customProperties); + return `${scopedProperty}:${value};`; + }) + .join(""); +} + +const layerPaintDeclarationProperties = new Set([ + "background", + "background-attachment", + "background-blend-mode", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-position-x", + "background-position-y", + "background-repeat", + "background-size", + "border", + "border-block", + "border-block-color", + "border-block-end", + "border-block-end-color", + "border-block-end-style", + "border-block-end-width", + "border-block-start", + "border-block-start-color", + "border-block-start-style", + "border-block-start-width", + "border-block-style", + "border-block-width", + "border-bottom", + "border-bottom-color", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-style", + "border-bottom-width", + "border-color", + "border-inline", + "border-inline-color", + "border-inline-end", + "border-inline-end-color", + "border-inline-end-style", + "border-inline-end-width", + "border-inline-start", + "border-inline-start-color", + "border-inline-start-style", + "border-inline-start-width", + "border-inline-style", + "border-inline-width", + "border-left", + "border-left-color", + "border-left-style", + "border-left-width", + "border-radius", + "border-right", + "border-right-color", + "border-right-style", + "border-right-width", + "border-style", + "border-top", + "border-top-color", + "border-top-left-radius", + "border-top-right-radius", + "border-top-style", + "border-top-width", + "border-width", + "box-shadow", + "outline", + "outline-color", + "outline-offset", + "outline-style", + "outline-width" +]); + +function rewriteScopedLayerPaintDeclarations(body: string, cssScope: AuthorSkinCssScope) { + return splitDeclarations(body) + .map((declaration) => { + const colon = declaration.indexOf(":"); + if (colon === -1) return ""; + + const property = declaration.slice(0, colon).trim(); + if (!layerPaintDeclarationProperties.has(property)) return ""; + + const value = replaceCustomPropertyTokens(declaration.slice(colon + 1), cssScope.customProperties); + return `${property}:${value};`; + }) + .join(""); +} + +function replaceKeyframeTokens(value: string, keyframes: ReadonlyMap) { + let output = value; + for (const [name, scopedName] of keyframes) { + output = output.replace( + new RegExp(`(^|[^-_a-zA-Z0-9])${escapeRegExp(name)}(?=$|[^-_a-zA-Z0-9])`, "g"), + (_match, prefix: string) => `${prefix}${scopedName}` + ); + } + return output; +} + +function replaceCustomPropertyTokens(value: string, customProperties: ReadonlyMap) { + if (!customProperties.size) return value; + let output = value; + for (const [name, scopedName] of customProperties) { + output = output.replace( + new RegExp(`(^|[^-_a-zA-Z0-9])${escapeRegExp(name)}(?=$|[^-_a-zA-Z0-9])`, "g"), + (_match, prefix: string) => `${prefix}${scopedName}` + ); + } + return output; +} + +function splitDeclarations(input: string) { + const declarations: string[] = []; + let current = ""; + let quote = ""; + let depth = 0; + + for (const char of input) { + if (quote) { + current += char; + if (char === quote) quote = ""; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + + if (char === "(") depth += 1; + if (char === ")" && depth > 0) depth -= 1; + + if (char === ";" && depth === 0) { + declarations.push(current); + current = ""; + } else { + current += char; + } + } + + if (current.trim()) declarations.push(current); + return declarations; +} + +function matchingBrace(input: string, open: number) { + let depth = 0; + let quote = ""; + + for (let index = open; index < input.length; index += 1) { + const char = input[index]; + if (quote) { + if (char === quote && input[index - 1] !== "\\") quote = ""; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function ProfileCustomSkin({ skin }: { skin: ProfileSkin }) { + return skin.bodyHtml ?
    : null; +} diff --git a/src/socialLinks.test.ts b/src/socialLinks.test.ts new file mode 100644 index 0000000..6c2f7d7 --- /dev/null +++ b/src/socialLinks.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { defaultSocialLinks, normalizeSocialLinks, normalizeStoredSocialLinks, SocialLinkValidationError } from "./socialLinks.js"; + +describe("social link validation", () => { + it("rejects unsafe Bliish links", () => { + for (const link of [ + "mailto:owner@example.test", + "https://user:pass@example.com/profile", + "https://example.com:8443/profile", + "javascript:alert(1)" + ]) { + expect(() => normalizeSocialLinks({ bliish: link })).toThrow(SocialLinkValidationError); + } + }); + + it("drops invalid stored links instead of rendering them", () => { + expect(normalizeStoredSocialLinks({ bliish: "javascript:alert(1)" })).toEqual(defaultSocialLinks); + }); +}); diff --git a/src/socialLinks.ts b/src/socialLinks.ts new file mode 100644 index 0000000..4931cd9 --- /dev/null +++ b/src/socialLinks.ts @@ -0,0 +1,110 @@ +import { limits } from "./policy.js"; +import { sourceProject } from "./project.js"; +import { recordFromUnknown, stringFromUnknown } from "./values.js"; + +export type SocialLinkPlatformConfig = { + id: string; + label: string; + icon?: { + src: string; + }; + pattern: string; + placeholder: string; + exampleHref: string; + exampleLabel: string; +}; + +const bliishExampleHref = new URL("/profile/bliish", sourceProject.provider.url).href; +const bliishExampleLabel = bliishExampleHref.replace(/^https?:\/\//, ""); + +export const socialLinkPlatforms = [ + { + id: "bliish", + label: "Bliish", + icon: { + src: "/static/icons/bliish.ico" + }, + pattern: "https?://[^\\s<>]+", + placeholder: bliishExampleHref, + exampleHref: bliishExampleHref, + exampleLabel: bliishExampleLabel + } +] as const satisfies readonly SocialLinkPlatformConfig[]; + +type SocialPlatform = (typeof socialLinkPlatforms)[number]["id"]; +export type SocialLinks = Record; + +export const defaultSocialLinks: SocialLinks = { + bliish: "" +}; + +export class SocialLinkValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "SocialLinkValidationError"; + } +} + +export function normalizeSocialLinks(input: Partial>) { + const links = { ...defaultSocialLinks }; + for (const platform of socialLinkPlatforms) { + links[platform.id] = normalizeSocialLink(platform.id, input[platform.id] ?? ""); + } + return links; +} + +export function normalizeStoredSocialLinks(input: unknown) { + const record = recordFromUnknown(input); + + const links = { ...defaultSocialLinks }; + for (const platform of socialLinkPlatforms) { + const value = stringFromUnknown(record[platform.id]); + try { + links[platform.id] = normalizeSocialLink(platform.id, value); + } catch { + links[platform.id] = ""; + } + } + return links; +} + +export function hasSocialLinks(links: SocialLinks) { + return socialLinkPlatforms.some((platform) => Boolean(links[platform.id])); +} + +function normalizeSocialLink(platform: SocialPlatform, value: string) { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.length > limits.socialLinkUrl) { + throw new SocialLinkValidationError(`${platformLabel(platform)} link must be ${limits.socialLinkUrl} characters or fewer.`); + } + + switch (platform) { + case "bliish": + return normalizeBliishLink(trimmed); + } +} + +function normalizeBliishLink(value: string) { + let url: URL; + try { + url = new URL(value); + } catch { + throw new SocialLinkValidationError("Bliish link must be a full URL."); + } + + if ( + !["http:", "https:"].includes(url.protocol) || + url.port || + url.username || + url.password + ) { + throw new SocialLinkValidationError("Bliish link must use http:// or https:// without embedded credentials."); + } + + return url.href; +} + +function platformLabel(platform: SocialPlatform) { + return socialLinkPlatforms.find((candidate) => candidate.id === platform)?.label ?? "Social"; +} diff --git a/src/text.ts b/src/text.ts new file mode 100644 index 0000000..c87feee --- /dev/null +++ b/src/text.ts @@ -0,0 +1,5 @@ +export function truncateText(input: string, max: number) { + const limit = Math.max(0, Math.floor(max)); + if (input.length <= limit) return input; + return limit <= 3 ? ".".repeat(limit) : `${input.slice(0, limit - 3)}...`; +} diff --git a/src/theme/colorPalette.ts b/src/theme/colorPalette.ts new file mode 100644 index 0000000..efe6c0b --- /dev/null +++ b/src/theme/colorPalette.ts @@ -0,0 +1,201 @@ +import { recordFromUnknown, stringFromUnknown } from "../values.js"; + +export const colorPaletteTokens = ["chrome", "chromeText", "accent", "accentText", "link", "backdrop", "page", "surface"] as const; + +export type ColorPaletteToken = (typeof colorPaletteTokens)[number]; +export type ColorPalette = Record; + +export const colorPaletteLabels = { + chrome: "Header", + chromeText: "Header text", + accent: "Accent", + accentText: "Accent text", + link: "Link", + backdrop: "Backdrop", + page: "Page", + surface: "Panel" +} satisfies Record; + +export const defaultColorPalette = { + chrome: "#7c3aed", + chromeText: "#ffffff", + accent: "#7c3aed", + accentText: "#ffffff", + link: "#6b21a8", + backdrop: "#e6e3ea", + page: "#ffffff", + surface: "#ffffff" +} satisfies ColorPalette; + +type Rgb = { r: number; g: number; b: number }; + +const colorInputPrefix = "color_"; +const hexPattern = /^#[0-9a-f]{6}$/i; + +export function colorPaletteFieldName(token: ColorPaletteToken) { + return `${colorInputPrefix}${token}`; +} + +export function colorPaletteFromForm(form: Record, fallback: ColorPalette = defaultColorPalette): ColorPalette { + return colorPaletteTokens.reduce((colors, token) => { + colors[token] = normalizeHex(stringFromUnknown(form[colorPaletteFieldName(token)]), fallback[token]); + return colors; + }, { ...fallback }); +} + +export function colorPaletteFromCssVariables( + css: string, + aliases: Record, + fallback: ColorPalette = defaultColorPalette +): ColorPalette { + return colorPaletteTokens.reduce((colors, token) => { + colors[token] = normalizeHex(firstSavedVariable(css, aliases[token]), fallback[token]); + return colors; + }, { ...fallback }); +} + +export function parseColorPaletteJson(value: string | null | undefined): ColorPalette | null { + if (!value) return null; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = recordFromUnknown(parsed); + return colorPaletteTokens.reduce((colors, token) => { + colors[token] = normalizeHex(stringFromUnknown(record[token]), defaultColorPalette[token]); + return colors; + }, { ...defaultColorPalette }); + } catch { + return null; + } +} + +export function colorPaletteJson(palette: ColorPalette) { + const stored = { ...defaultColorPalette }; + for (const token of colorPaletteTokens) { + stored[token] = normalizeHex(palette[token], defaultColorPalette[token]); + } + return JSON.stringify(stored); +} + +function colorPalettesEqual(left: ColorPalette, right: ColorPalette) { + return colorPaletteTokens.every((token) => normalizeHex(left[token], defaultColorPalette[token]) === normalizeHex(right[token], defaultColorPalette[token])); +} + +export function isDefaultColorPalette(palette: ColorPalette) { + return colorPalettesEqual(palette, defaultColorPalette); +} + +export function deriveColorPalette(source: ColorPalette) { + const pageText = readableText(source.page); + const surfaceText = readableText(source.surface); + const chrome = source.chrome; + + return { + accent: source.accent, + accentText: source.accentText, + backdrop: source.backdrop, + chrome, + chromeText: source.chromeText, + muted: mixColor(surfaceText, source.surface, 0.42), + page: source.page, + pageLink: source.link, + pageLinkHover: readableAccent(mixColor(source.link, pageText, 0.35), source.page), + pageText, + surface: source.surface, + surfaceLink: source.link, + surfaceLinkHover: readableAccent(mixColor(source.link, surfaceText, 0.35), source.surface), + surfaceText + }; +} + +export function cssCustomPropertyDeclarations(variables: Record) { + return Object.entries(variables).map(([name, value]) => ` --${name}:${value};`); +} + +function normalizeHex(value: string, fallback: string) { + const normalized = value.trim().toLowerCase(); + return hexPattern.test(normalized) ? normalized : fallback; +} + +function savedVariable(css: string, variable: string) { + const match = new RegExp(`--${variable}\\s*:\\s*(#[0-9a-f]{6})\\b`, "i").exec(css); + return match ? match[1] : ""; +} + +function firstSavedVariable(css: string, variables: readonly string[]) { + for (const variable of variables) { + const value = savedVariable(css, variable); + if (value) return value; + } + return ""; +} + +function clamp(value: number) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function hexToRgb(hex: string): Rgb { + const value = hexPattern.test(hex) ? hex.slice(1) : "000000"; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16) + }; +} + +function rgbToHex(color: Rgb) { + return `#${[color.r, color.g, color.b].map((channel) => clamp(channel).toString(16).padStart(2, "0")).join("")}`; +} + +export function mixColor(color: string, target: string, amount: number) { + const left = hexToRgb(color); + const right = hexToRgb(target); + return rgbToHex({ + r: left.r + (right.r - left.r) * amount, + g: left.g + (right.g - left.g) * amount, + b: left.b + (right.b - left.b) * amount + }); +} + +export function headerChromeColor(palette: Pick) { + return mixColor(palette.chrome, "#000000", 0.28); +} + +function luminance(hex: string) { + const color = hexToRgb(hex); + const [red, green, blue] = [color.r, color.g, color.b].map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + }); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} + +function contrast(foreground: string, background: string) { + const light = Math.max(luminance(foreground), luminance(background)); + const dark = Math.min(luminance(foreground), luminance(background)); + return (light + 0.05) / (dark + 0.05); +} + +function readableText(background: string) { + return contrast("#111111", background) >= contrast("#ffffff", background) ? "#111111" : "#ffffff"; +} + +function readableAccent(accent: string, background: string) { + if (contrast(accent, background) >= 4.5) return accent; + let best = readableText(background); + let bestRatio = contrast(best, background); + + for (const target of ["#000000", "#ffffff"]) { + for (let step = 1; step <= 8; step += 1) { + const candidate = mixColor(accent, target, step / 10); + const ratio = contrast(candidate, background); + if (ratio > bestRatio) { + best = candidate; + bestRatio = ratio; + } + if (ratio >= 4.5) break; + } + } + + return best; +} diff --git a/src/theme/themeCss.ts b/src/theme/themeCss.ts new file mode 100644 index 0000000..7e07eab --- /dev/null +++ b/src/theme/themeCss.ts @@ -0,0 +1,36 @@ +import { cssCustomPropertyDeclarations, deriveColorPalette, type ColorPalette } from "./colorPalette.js"; + +export function themeCssFromPalette(palette: ColorPalette) { + const colors = deriveColorPalette(palette); + const variables = { + "app-theme-backdrop": colors.backdrop, + "app-theme-page": colors.page, + "app-theme-surface": colors.surface, + "app-theme-chrome": colors.chrome, + "app-theme-chrome-text": colors.chromeText, + "app-theme-accent": colors.accent, + "app-theme-accent-text": colors.accentText, + "app-theme-link": colors.pageLink, + "app-theme-link-hover": colors.pageLinkHover, + "app-theme-surface-link": colors.surfaceLink, + "app-theme-surface-link-hover": colors.surfaceLinkHover, + "app-theme-page-text": colors.pageText, + "app-theme-surface-text": colors.surfaceText, + "app-theme-muted": colors.muted, + "theme-backdrop": colors.backdrop, + "theme-page": colors.page, + "theme-surface": colors.surface, + "theme-chrome": colors.chrome, + "theme-chrome-text": colors.chromeText, + "theme-accent": colors.accent, + "theme-accent-text": colors.accentText, + "theme-link": colors.pageLink, + "theme-link-hover": colors.pageLinkHover, + "theme-surface-link": colors.surfaceLink, + "theme-surface-link-hover": colors.surfaceLinkHover, + "theme-page-text": colors.pageText, + "theme-surface-text": colors.surfaceText, + "theme-muted": colors.muted + }; + return `:root{\n${cssCustomPropertyDeclarations(variables).join("\n")}\n}\n`; +} diff --git a/src/timeZones.ts b/src/timeZones.ts new file mode 100644 index 0000000..6e2a653 --- /dev/null +++ b/src/timeZones.ts @@ -0,0 +1,46 @@ +export const defaultTimeZone = "UTC"; + +const fallbackTimeZones = [ + "Africa/Cairo", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/New_York", + "America/Phoenix", + "America/Toronto", + "Asia/Bangkok", + "Asia/Dubai", + "Asia/Hong_Kong", + "Asia/Kolkata", + "Asia/Seoul", + "Asia/Singapore", + "Asia/Tokyo", + "Australia/Sydney", + "Europe/Berlin", + "Europe/London", + "Europe/Paris", + "Pacific/Auckland" +] as const; + +let supportedCache: string[] | undefined; +type IntlWithTimeZones = typeof Intl & { supportedValuesOf?: (key: "timeZone") => string[] }; + +export function supportedTimeZones() { + if (supportedCache) return supportedCache; + + const zones = (Intl as IntlWithTimeZones).supportedValuesOf?.("timeZone") ?? [...fallbackTimeZones]; + supportedCache = [defaultTimeZone, ...zones.filter((zone) => zone !== defaultTimeZone)]; + return supportedCache; +} + +export function isSupportedTimeZone(value: string) { + return supportedTimeZones().includes(value); +} + +export function normalizeTimeZone(value: unknown) { + return typeof value === "string" && isSupportedTimeZone(value) ? value : defaultTimeZone; +} + +export function timeZoneOptionLabel(timeZone: string) { + return timeZone === defaultTimeZone ? defaultTimeZone : timeZone.replaceAll("_", " "); +} diff --git a/src/types/lucide-static.d.ts b/src/types/lucide-static.d.ts new file mode 100644 index 0000000..2c5bea0 --- /dev/null +++ b/src/types/lucide-static.d.ts @@ -0,0 +1,4 @@ +declare module "lucide-static/dist/esm/icons/*.mjs" { + const svg: string; + export default svg; +} diff --git a/src/ui/actions.tsx b/src/ui/actions.tsx new file mode 100644 index 0000000..7f464b5 --- /dev/null +++ b/src/ui/actions.tsx @@ -0,0 +1,47 @@ +import type { Child } from "hono/jsx"; +import { classNames } from "./classes.js"; +import { Icon, type IconName } from "./icons.js"; + +type ActionKind = "add" | "apply" | "comment" | "delete" | "edit" | "favorite" | "leave" | "lock" | "post" | "prop" | "reply" | "report" | "save" | "search" | "send" | "unlock" | "upload"; + +const actionIcons: Record = { + add: "add", + apply: "check", + comment: "comment", + delete: "delete", + edit: "edit", + favorite: "favorite", + leave: "user-minus", + lock: "lock", + post: "send", + prop: "prop", + reply: "reply", + report: "report", + save: "save", + search: "search", + send: "send", + unlock: "unlock", + upload: "upload" +}; + +export function ActionLabel(props: { action: ActionKind; children: Child }) { + return ( + <> + + {props.children} + + ); +} + +export function ActionBar(props: { primary?: Child; secondary?: Child; className?: string }) { + return ( +
    + {props.primary ?
    {props.primary}
    : null} + {props.secondary ?
    {props.secondary}
    : null} +
    + ); +} + +export function toggleButtonClass(selected: boolean) { + return selected ? "button--selected" : "button--secondary"; +} diff --git a/src/ui/actors.tsx b/src/ui/actors.tsx new file mode 100644 index 0000000..cd3b6d3 --- /dev/null +++ b/src/ui/actors.tsx @@ -0,0 +1,35 @@ +import { profilePath } from "../paths.js"; +import { MetaSubjectLink } from "./meta.js"; + +export type ActorSummaryItem = { + handle: string; + id?: number | string | null; + name: string; +}; + +export function ActorSummary(props: { actors: ActorSummaryItem[]; hiddenCount?: number; maxVisible?: number }) { + const visible = props.actors.slice(0, props.maxVisible ?? 3); + const hidden = Math.max(0, props.actors.length - visible.length) + (props.hiddenCount ?? 0); + return ( + <> + {visible.map((actor, index) => ( + + {actorSeparator(index, visible.length, hidden)} + {actor.name} + + ))} + {hidden ? ( + <> + {visible.length ? " and " : null} + {hidden} other {hidden === 1 ? "person" : "people"} + + ) : null} + + ); +} + +function actorSeparator(index: number, visibleCount: number, hiddenCount: number) { + if (index === 0) return null; + if (!hiddenCount && index === visibleCount - 1) return " and "; + return ", "; +} diff --git a/src/ui/avatars.tsx b/src/ui/avatars.tsx new file mode 100644 index 0000000..0cb55b8 --- /dev/null +++ b/src/ui/avatars.tsx @@ -0,0 +1,42 @@ +import { defaultProfileImageNames } from "../policy.js"; +import { classNames } from "./classes.js"; +import { Icon } from "./icons.js"; +import { profileImagePath } from "../paths.js"; + +type ProfileImageVariant = "avatar-compact" | "profile" | "edit-preview"; +type ProfileImageProps = { + filename: string; + alt?: string; + className?: string; + variant?: ProfileImageVariant; + loading?: "lazy" | "eager"; +}; + +const profileImageVariants: Record = { + "avatar-compact": "profile-image--avatar-compact", + profile: "profile-image--profile", + "edit-preview": "profile-image--edit-preview" +}; + +export function ProfileImage(props: ProfileImageProps) { + const alt = props.alt ?? "profile picture"; + const variantClass = props.variant ? profileImageVariants[props.variant] : undefined; + const classes = classNames(variantClass, props.className); + if (defaultProfileImageNames.has(props.filename)) { + return ( + + + + ); + } + return {alt}; +} + +export function ProfileImageLink(props: ProfileImageProps & { href: string; label: string; linkClassName?: string }) { + const { href, label, linkClassName, ...imageProps } = props; + return ( + + + + ); +} diff --git a/src/ui/badges.tsx b/src/ui/badges.tsx new file mode 100644 index 0000000..68bd82b --- /dev/null +++ b/src/ui/badges.tsx @@ -0,0 +1,16 @@ +import { classNames } from "./classes.js"; + +function formatBadgeCount(count: number) { + return count > 99 ? "99+" : String(count); +} + +type CountBadgeTone = "default" | "attention"; + +export function CountBadge(props: { className?: string; count: number; label?: string; tone?: CountBadgeTone }) { + const toneClass = props.tone && props.tone !== "default" ? `count-badge--${props.tone}` : undefined; + return ( + + {formatBadgeCount(props.count)} + + ); +} diff --git a/src/ui/classes.ts b/src/ui/classes.ts new file mode 100644 index 0000000..96c5efd --- /dev/null +++ b/src/ui/classes.ts @@ -0,0 +1,4 @@ +export function classNames(...classes: Array) { + const value = classes.filter(Boolean).join(" "); + return value || undefined; +} diff --git a/src/ui/colorSwatches.tsx b/src/ui/colorSwatches.tsx new file mode 100644 index 0000000..f5a491e --- /dev/null +++ b/src/ui/colorSwatches.tsx @@ -0,0 +1,30 @@ +import { + colorPaletteFieldName, + colorPaletteLabels, + colorPaletteTokens, + type ColorPalette, + type ColorPaletteToken +} from "../theme/colorPalette.js"; + +export const colorSwatchesStylesheet = "/static/css/components/color-swatches.css"; + +export function ColorSwatches({ palette }: { palette: ColorPalette }) { + return ( +
    +
    + {colorPaletteTokens.map((token) => ( + + ))} +
    +
    + ); +} + +function ColorSwatch({ label, token, value }: { label: string; token: ColorPaletteToken; value: string }) { + return ( + + ); +} diff --git a/src/ui/comments.tsx b/src/ui/comments.tsx new file mode 100644 index 0000000..06f471d --- /dev/null +++ b/src/ui/comments.tsx @@ -0,0 +1,142 @@ +import { ActionBar, ActionLabel } from "./actions.js"; +import { classNames } from "./classes.js"; +import { UserContent } from "./userContent.js"; +import { CharacterLimitHint, CsrfInput } from "./forms.js"; +import { DiscussionEntry } from "./discussion.js"; +import { Panel } from "./panels.js"; +import { anchors } from "../anchors.js"; +import { reportPath } from "../paths.js"; +import type { CommentItem } from "../models.js"; +import { limits, type ReportSubjectType } from "../policy.js"; +import { canModerateTarget } from "../roles.js"; +import type { CurrentUser } from "../currentUser.js"; + +export function CommentForm(props: { + action: string; + csrf: string; + button?: string; + maxLength?: number; + parentId?: number; + rows?: number; +}) { + const label = props.button ?? "Add comment"; + const maxLength = props.maxLength ?? limits.userText; + return ( +
    + + {props.parentId ? : null} + +
    +
    + + +
    +
    + + ); +} + +export function CommentList(props: { + comments: CommentItem[]; + user?: CurrentUser | null; + csrf?: string; + deleteOwnerIds?: readonly number[]; + deleteAction?: string; + replyAction?: string; + reportType?: ReportSubjectType; +}) { + if (!props.comments.length) return

    No comments yet.

    ; + + return ( +
    + {props.comments.map((comment) => { + const user = props.user; + const deleteForm = + user && props.csrf && props.deleteAction && canDeleteComment(user, comment, props.deleteOwnerIds) + ? { action: `${props.deleteAction}/${comment.id}/delete`, csrf: props.csrf } + : undefined; + const reply = user && props.csrf && props.replyAction && comment.parentId === null ? { action: props.replyAction, csrf: props.csrf } : undefined; + const replyAction = reply ? ( +
    + Reply + +
    + ) : null; + const hasUtilityActions = Boolean(props.reportType || deleteForm); + const utilityActions = hasUtilityActions ? ( + <> + {props.reportType ? Report : null} + {deleteForm ? ( +
    + + + + ) : null} + + ) : null; + return ( + + + {replyAction || utilityActions ? ( + + ) : null} + + ); + })} +
    + ); +} + +function canDeleteComment(user: CurrentUser, comment: CommentItem, ownerIds: readonly number[] | undefined) { + return ( + user.id === comment.authorId || + Boolean(ownerIds?.includes(user.id)) || + canModerateTarget(user, { id: comment.authorId, role: comment.authorRole }) + ); +} + +export function CommentPanel(props: { + action: string; + comments: CommentItem[]; + csrf: string; + deleteAction: string; + deleteOwnerIds: readonly number[]; + fullComments?: boolean; + fullHref?: string; + reportType: ReportSubjectType; + title?: string; + user: CurrentUser | null; +}) { + return ( + + {props.fullHref ? ( +

    + + Displaying {props.comments.length} comments + {!props.fullComments && props.comments.length ? <> ( View all ) : null} + +

    + ) : null} + {props.user ? :

    Log in to add a comment.

    } + +
    + ); +} diff --git a/src/ui/discussion.tsx b/src/ui/discussion.tsx new file mode 100644 index 0000000..1e3688a --- /dev/null +++ b/src/ui/discussion.tsx @@ -0,0 +1,41 @@ +import type { Child } from "hono/jsx"; +import { classNames } from "./classes.js"; +import { MetaSubjectLink } from "./meta.js"; +import { ProfileImageLink } from "./avatars.js"; +import { AuthorSkinBoundary, profileSkinPart } from "../skins/rendering.js"; +import { profilePath } from "../paths.js"; +import { LocalizedTime } from "./time.js"; + +export function DiscussionEntry(props: { + authorId: number; + authorHandle: string; + authorSkinHtml: string; + children: Child; + className?: string; + createdAt: string; + id?: string; + pfp: string; + username: string; +}) { + return ( + +
    + +
    + + {props.children} +
    +
    +
    + ); +} diff --git a/src/ui/engagement.tsx b/src/ui/engagement.tsx new file mode 100644 index 0000000..ed0ddbc --- /dev/null +++ b/src/ui/engagement.tsx @@ -0,0 +1,41 @@ +import { ActionLabel, toggleButtonClass } from "./actions.js"; +import { CsrfInput } from "./forms.js"; + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +export function PropAction(props: { + action: string; + count: number; + csrf: string; + propped: boolean; +}) { + const label = pluralize(props.count, "Prop"); + + return ( +
    + + + + ); +} + +export function PropCount(props: { count: number; propped?: boolean }) { + return ( + + + + ); +} + +function PropLabel({ count, label }: { count: number; label: string }) { + return {count} {label}; +} diff --git a/src/ui/forms.tsx b/src/ui/forms.tsx new file mode 100644 index 0000000..17e0935 --- /dev/null +++ b/src/ui/forms.tsx @@ -0,0 +1,81 @@ +import type { ViewChild } from "./types.js"; +import { classNames } from "./classes.js"; +import { maxCharacterLimitLabel } from "../policy.js"; + +type FormMessageTone = "error" | "info" | "success"; + +export function CsrfInput({ csrf }: { csrf: string }) { + return ; +} + +export function FormStack(props: { action: string; actionFragment?: string; children: ViewChild; className?: string; id?: string; multipart?: boolean }) { + return ( +
    + {props.children} +
    + ); +} + +function formActionUrl(action: string, fragment?: string) { + if (!fragment) return action; + return `${action.replace(/#.*$/, "")}#${encodeURIComponent(fragment)}`; +} + +export function FormField(props: { children: ViewChild; hint?: ViewChild; label?: ViewChild }) { + return ( + + ); +} + +export function FormActions(props: { children: ViewChild; className?: string; hint?: ViewChild }) { + return ( +
    + {props.children} + {props.hint ? {props.hint} : null} +
    + ); +} + +function FormMessage(props: { children: ViewChild; tone?: FormMessageTone }) { + const tone = props.tone ?? "info"; + return ( +

    + {props.children} +

    + ); +} + +export function FormError(props: { children?: ViewChild }) { + return props.children ? {props.children} : null; +} + +export function FormSuccess(props: { children?: ViewChild }) { + return props.children ? {props.children} : null; +} + +export function CharacterLimitHint(props: { maxLength: number; note?: ViewChild; className?: string }) { + return ( + + {maxCharacterLimitLabel(props.maxLength)} + {props.note ? <> | {props.note} : null} + + ); +} diff --git a/src/ui/groups.tsx b/src/ui/groups.tsx new file mode 100644 index 0000000..6e04c69 --- /dev/null +++ b/src/ui/groups.tsx @@ -0,0 +1,77 @@ +import type { GroupItem } from "../models.js"; +import { groupPath, profilePath } from "../paths.js"; +import { plainTextFromHtml } from "../server/security/html.js"; +import { truncateText } from "../text.js"; +import { classNames } from "./classes.js"; +import { MetaSubjectLink } from "./meta.js"; +import { Panel } from "./panels.js"; +import type { DataAttributes, ViewChild } from "./types.js"; + +type CommunityBoxGroup = GroupItem & { + href?: string | null; +}; + +const communityBoxLimit = 3; + +export function GroupSummaryCard({ group }: { group: GroupItem }) { + return ( +
    +

    {group.name}

    +

    Owner: {group.ownerName}

    +

    {groupDescription(group, 180)}

    +

    {memberCountLabel(group.memberCount)}

    +
    + ); +} + +function CommunityCard({ group }: { group: CommunityBoxGroup }) { + const href = group.href === undefined ? groupPath(group) : group.href; + return ( +
    +

    {group.name}

    +

    {groupDescription(group, 90)}

    +
    + ); +} + +function CommunityCardLink(props: { href: string | null; children: ViewChild }) { + if (props.href) return {props.children}; + return {props.children}; +} + +export function CommunityBox(props: { + title: string; + groups: CommunityBoxGroup[]; + more?: string; + prefix?: ViewChild; + className?: string; + singleLine?: boolean; + dataAttributes?: DataAttributes; +}) { + const groups = props.groups.slice(0, communityBoxLimit); + return ( + [view more] : null} + title={props.title} + tone="soft" + > + {props.prefix ?
    {props.prefix}
    : null} + {groups.length ? ( +
    + {groups.map((group) => )} +
    + ) :

    No communities found.

    } +
    + ); +} + +function groupDescription(group: GroupItem, length: number) { + return truncateText(plainTextFromHtml(group.descriptionHtml), length); +} + +function memberCountLabel(count: number) { + return `${count} ${count === 1 ? "member" : "members"}`; +} diff --git a/src/ui/html.ts b/src/ui/html.ts new file mode 100644 index 0000000..a0efe26 --- /dev/null +++ b/src/ui/html.ts @@ -0,0 +1,4 @@ +export function trustedHtml(html: string) { + // User-content render boundary: pass stored sanitizer output, never raw form input. + return { __html: html }; +} diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx new file mode 100644 index 0000000..69dcc91 --- /dev/null +++ b/src/ui/icons.tsx @@ -0,0 +1,109 @@ +import AddIcon from "lucide-static/dist/esm/icons/user-plus.mjs"; +import AuditIcon from "lucide-static/dist/esm/icons/scroll-text.mjs"; +import BellIcon from "lucide-static/dist/esm/icons/bell.mjs"; +import BlogIcon from "lucide-static/dist/esm/icons/book-open-text.mjs"; +import CheckIcon from "lucide-static/dist/esm/icons/check.mjs"; +import CommentIcon from "lucide-static/dist/esm/icons/message-circle.mjs"; +import DatabaseIcon from "lucide-static/dist/esm/icons/database.mjs"; +import DeleteIcon from "lucide-static/dist/esm/icons/trash-2.mjs"; +import EditIcon from "lucide-static/dist/esm/icons/pencil.mjs"; +import EmailIcon from "lucide-static/dist/esm/icons/mail.mjs"; +import FavoriteIcon from "lucide-static/dist/esm/icons/star.mjs"; +import ForwardIcon from "lucide-static/dist/esm/icons/send.mjs"; +import GaugeIcon from "lucide-static/dist/esm/icons/gauge.mjs"; +import GroupIcon from "lucide-static/dist/esm/icons/users.mjs"; +import LinkIcon from "lucide-static/dist/esm/icons/link.mjs"; +import LockIcon from "lucide-static/dist/esm/icons/lock.mjs"; +import LockOpenIcon from "lucide-static/dist/esm/icons/lock-open.mjs"; +import MessageIcon from "lucide-static/dist/esm/icons/message-square.mjs"; +import MoonIcon from "lucide-static/dist/esm/icons/moon.mjs"; +import PropIcon from "lucide-static/dist/esm/icons/hand-heart.mjs"; +import RefreshIcon from "lucide-static/dist/esm/icons/refresh-cw.mjs"; +import ReplyIcon from "lucide-static/dist/esm/icons/reply.mjs"; +import ReportIcon from "lucide-static/dist/esm/icons/triangle-alert.mjs"; +import SaveIcon from "lucide-static/dist/esm/icons/save.mjs"; +import SearchIcon from "lucide-static/dist/esm/icons/search.mjs"; +import SettingsIcon from "lucide-static/dist/esm/icons/settings.mjs"; +import SunIcon from "lucide-static/dist/esm/icons/sun.mjs"; +import UploadIcon from "lucide-static/dist/esm/icons/upload.mjs"; +import UserIcon from "lucide-static/dist/esm/icons/user.mjs"; +import UserMinusIcon from "lucide-static/dist/esm/icons/user-minus.mjs"; +import { brandIconSvg } from "../brand.js"; +import { trustedHtml } from "./html.js"; + +export type IconName = "add" | "audit" | "avatar" | "blog" | "brand" | "check" | "comment" | "database" | "delete" | "edit" | "email" | "favorite" | "forward" | "github" | "group" | "link" | "lock" | "message" | "moon" | "notifications" | "prop" | "rate-limit" | "reddit" | "refresh" | "reply" | "report" | "save" | "search" | "send" | "settings" | "sun" | "unlock" | "upload" | "user" | "user-minus"; + +const DefaultAvatarIcon = [ + '', + '', + '', + '', + "" +].join(""); + +const GithubIcon = [ + '', + '', + "" +].join(""); + +const RedditIcon = [ + '', + '', + "" +].join(""); + +const icons: Record = { + add: AddIcon, + audit: AuditIcon, + avatar: DefaultAvatarIcon, + blog: BlogIcon, + brand: brandIconSvg, + check: CheckIcon, + comment: CommentIcon, + database: DatabaseIcon, + delete: DeleteIcon, + edit: EditIcon, + email: EmailIcon, + favorite: FavoriteIcon, + forward: ForwardIcon, + github: GithubIcon, + group: GroupIcon, + link: LinkIcon, + lock: LockIcon, + message: MessageIcon, + moon: MoonIcon, + notifications: BellIcon, + prop: PropIcon, + "rate-limit": GaugeIcon, + reddit: RedditIcon, + refresh: RefreshIcon, + reply: ReplyIcon, + report: ReportIcon, + save: SaveIcon, + search: SearchIcon, + send: ForwardIcon, + settings: SettingsIcon, + sun: SunIcon, + unlock: LockOpenIcon, + upload: UploadIcon, + user: UserIcon, + "user-minus": UserMinusIcon +}; + +export function SvgIcon({ svg, label }: { svg: string; label?: string }) { + // Icon SVG strings are static imports or local constants, not user content. + return ( + + ); +} + +export function Icon({ name, label }: { name: IconName; label?: string }) { + return ; +} diff --git a/src/ui/links.tsx b/src/ui/links.tsx new file mode 100644 index 0000000..dbb696c --- /dev/null +++ b/src/ui/links.tsx @@ -0,0 +1,33 @@ +import { pageLinks, type PageLinkKey } from "../navigation.js"; +import type { ViewChild } from "./types.js"; + +export type LinkItem = readonly [label: string, href: string]; + +export function BackToPage(props: { page: PageLinkKey }) { + const page = pageLinks[props.page]; + return ; +} + +export function BackLink(props: { href: string; label: string }) { + return ( + + ); +} + +export function InlineLinks(props: { links: LinkItem[]; prefix?: ViewChild | readonly ViewChild[] }) { + const prefixItems = props.prefix === undefined ? [] : Array.isArray(props.prefix) ? props.prefix : [props.prefix]; + return ( + + {prefixItems.map((item) => ( + {item} + ))} + {props.links.map(([label, href]) => ( + + {label} + + ))} + + ); +} diff --git a/src/ui/meta.tsx b/src/ui/meta.tsx new file mode 100644 index 0000000..00bf610 --- /dev/null +++ b/src/ui/meta.tsx @@ -0,0 +1,9 @@ +import type { Child } from "hono/jsx"; + +export function MetaSubject(props: { children: Child }) { + return {props.children}; +} + +export function MetaSubjectLink(props: { children: Child; href: string }) { + return {props.children}; +} diff --git a/src/ui/pagination.tsx b/src/ui/pagination.tsx new file mode 100644 index 0000000..b908d29 --- /dev/null +++ b/src/ui/pagination.tsx @@ -0,0 +1,17 @@ +import type { Child } from "hono/jsx"; + +export function PaginationNav(props: { + nextHref?: string | null; + nextLabel?: Child; + resetHref?: string | null; + resetLabel?: Child; +}) { + if (!props.nextHref && !props.resetHref) return null; + + return ( + + ); +} diff --git a/src/ui/panels.tsx b/src/ui/panels.tsx new file mode 100644 index 0000000..8f9f052 --- /dev/null +++ b/src/ui/panels.tsx @@ -0,0 +1,46 @@ +import type { Child } from "hono/jsx"; +import { classNames } from "./classes.js"; +import type { DataAttributes } from "./types.js"; + +type HeadingLevel = "h1" | "h2" | "h3" | "h4"; +type PanelTone = "strong" | "soft"; + +function PanelHeading(props: { level: HeadingLevel; children: Child }) { + switch (props.level) { + case "h1": + return

    {props.children}

    ; + case "h2": + return

    {props.children}

    ; + case "h3": + return

    {props.children}

    ; + case "h4": + return

    {props.children}

    ; + } +} + +export function Panel(props: { + bodyClassName?: string; + children: Child; + className?: string; + dataAttributes?: DataAttributes; + headerAction?: Child; + headingLevel?: HeadingLevel; + id?: string; + title: Child; + tone?: PanelTone; +}) { + const tone = props.tone ?? "strong"; + return ( +
    +
    + {props.title} + {props.headerAction ?
    {props.headerAction}
    : null} +
    +
    {props.children}
    +
    + ); +} diff --git a/src/ui/people.tsx b/src/ui/people.tsx new file mode 100644 index 0000000..e7b34a1 --- /dev/null +++ b/src/ui/people.tsx @@ -0,0 +1,84 @@ +import { classNames } from "./classes.js"; +import { ProfileImage } from "./avatars.js"; +import { Panel } from "./panels.js"; +import { profilePath } from "../paths.js"; +import type { PersonCard } from "../models.js"; +import type { DataAttributes, ViewChild } from "./types.js"; + +export type PeopleBoxPerson = PersonCard & { + href?: string | null; + imageSrc?: string; +}; + +export function Person({ person }: { person: PeopleBoxPerson }) { + const href = person.href === undefined ? profilePath(person) : person.href; + const alt = `${person.username}'s profile picture`; + return ( +
    + + {person.imageSrc ? ( + {alt} + ) : ( + + )} +

    {person.username}

    +
    +
    + ); +} + +function PersonCardIdentity(props: { href: string | null; children: ViewChild }) { + const className = classNames("person-card__identity", props.href ? "person-card__identity--link" : "person-card__identity--disabled"); + if (props.href) return {props.children}; + return {props.children}; +} + +export function PersonActionCard(props: { person: PersonCard; children: ViewChild }) { + return ( +
    + + + {props.person.username} + +
    {props.children}
    +
    + ); +} + +export function PeopleBox(props: { + title: string; + people: PeopleBoxPerson[]; + more?: string; + prefix?: ViewChild; + className?: string; + singleLine?: boolean; + dataAttributes?: DataAttributes; +}) { + return ( + [view more] : null} + title={props.title} + tone="soft" + > + {props.prefix ?
    {props.prefix}
    : null} + {props.people.length ? ( +
    + {props.people.map((person) => )} +
    + ) :

    No users found.

    } +
    + ); +} diff --git a/src/ui/previews.tsx b/src/ui/previews.tsx new file mode 100644 index 0000000..8a0902e --- /dev/null +++ b/src/ui/previews.tsx @@ -0,0 +1,9 @@ +import { truncateText } from "../text.js"; + +export function PreviewTitleLink(props: { href: string; title: string; titleLength: number; actionLabel?: string }) { + return ( + <> + {truncateText(props.title, props.titleLength)} ({props.actionLabel ?? "View more"}) + + ); +} diff --git a/src/ui/socialLinks.tsx b/src/ui/socialLinks.tsx new file mode 100644 index 0000000..bd4bdd4 --- /dev/null +++ b/src/ui/socialLinks.tsx @@ -0,0 +1,22 @@ +import type { SocialLinkPlatformConfig } from "../socialLinks.js"; +import { Icon } from "./icons.js"; + +type SocialLinkIconPlatform = Pick; +type SocialLinkLabelPlatform = Pick; + +export function SocialLinkIcon({ platform }: { platform: SocialLinkIconPlatform }) { + if (platform.icon) { + return ; + } + + return ; +} + +export function SocialLinkLabel({ platform }: { platform: SocialLinkLabelPlatform }) { + return ( + + ); +} diff --git a/src/ui/sourceProject.tsx b/src/ui/sourceProject.tsx new file mode 100644 index 0000000..8e75294 --- /dev/null +++ b/src/ui/sourceProject.tsx @@ -0,0 +1,10 @@ +import { sourceProject, sourceProjectDescription, sourceProjectPoweredByPrefix } from "../project.js"; +import type { ViewChild } from "./types.js"; + +export function SourceProjectPoweredBySentence({ suffix = "." }: { suffix?: ViewChild }) { + return ( + <> + {sourceProjectPoweredByPrefix} {sourceProject.name}, {sourceProjectDescription}{suffix} + + ); +} diff --git a/src/ui/time.tsx b/src/ui/time.tsx new file mode 100644 index 0000000..51c535c --- /dev/null +++ b/src/ui/time.tsx @@ -0,0 +1,85 @@ +import { createContext, useContext } from "hono/jsx"; +import { defaultTimeZone, normalizeTimeZone } from "../timeZones.js"; +import type { ViewChild } from "./types.js"; + +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; +const sqliteTimestampPattern = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/; +const timezonePattern = /(?:Z|[+-]\d{2}:?\d{2})$/i; +const timestampSeparator = "\u00b7"; +const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; +const TimeZoneContext = createContext(defaultTimeZone); + +export function TimeZoneProvider(props: { children: ViewChild; timeZone?: string | null }) { + return {props.children}; +} + +export function LocalizedTime(props: { value: string }) { + const datetime = normalizeTimestamp(props.value); + const timeZone = currentDisplayTimeZone(); + + return ; +} + +function normalizeTimestamp(value: string) { + const trimmed = value.trim(); + if (!trimmed) return value; + + const sqliteTimestamp = sqliteTimestampPattern.exec(trimmed); + if (sqliteTimestamp) return `${sqliteTimestamp[1]}T${sqliteTimestamp[2]}Z`; + if (dateOnlyPattern.test(trimmed)) return trimmed; + if (trimmed.includes("T") && !timezonePattern.test(trimmed)) return `${trimmed}Z`; + + return trimmed; +} + +function formatTimestamp(value: string, fallback = value, timeZone = defaultTimeZone) { + const timestamp = normalizeTimestamp(value); + + if (dateOnlyPattern.test(timestamp)) { + const [year, month, day] = timestamp.split("-").map(Number); + if (validDateParts(year, month, day)) return `${monthNames[month - 1]} ${day}, ${year}`; + return fallback; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return fallback; + + return formatDateTime(date, normalizeTimeZone(timeZone)); +} + +function validDateParts(year: number, month: number, day: number) { + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return false; + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + +function currentDisplayTimeZone() { + return normalizeTimeZone(useContext(TimeZoneContext)); +} + +function formatDateTime(date: Date, timeZone: string) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short" + }).formatToParts(date); + + const month = part(parts, "month"); + const day = part(parts, "day"); + const year = part(parts, "year"); + const hour = part(parts, "hour"); + const minute = part(parts, "minute"); + const zoneName = part(parts, "timeZoneName"); + + if (!month || !day || !year || !hour || !minute || !zoneName) return date.toISOString(); + return `${month} ${day}, ${year} ${timestampSeparator} ${hour}:${minute} ${zoneName}`; +} + +function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) { + return parts.find((item) => item.type === type)?.value; +} diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..ea2c7c2 --- /dev/null +++ b/src/ui/types.ts @@ -0,0 +1,4 @@ +import type { Child } from "hono/jsx"; + +export type DataAttributes = Record<`data-${string}`, string | number | boolean | undefined>; +export type ViewChild = Child; diff --git a/src/ui/userContent.tsx b/src/ui/userContent.tsx new file mode 100644 index 0000000..eecadd6 --- /dev/null +++ b/src/ui/userContent.tsx @@ -0,0 +1,14 @@ +import { classNames } from "./classes.js"; +import { trustedHtml } from "./html.js"; +import type { DataAttributes } from "./types.js"; + +export function UserContent(props: { className?: string; dataAttributes?: DataAttributes; html: string; itemprop?: string }) { + return ( +
    + ); +} diff --git a/src/values.ts b/src/values.ts new file mode 100644 index 0000000..e741980 --- /dev/null +++ b/src/values.ts @@ -0,0 +1,12 @@ +export function stringFromUnknown(value: unknown) { + return typeof value === "string" ? value : ""; +} + +export function recordFromUnknown(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + record[key] = entry; + } + return record; +} diff --git a/src/views/account/deleteAccount.tsx b/src/views/account/deleteAccount.tsx new file mode 100644 index 0000000..c82a7d0 --- /dev/null +++ b/src/views/account/deleteAccount.tsx @@ -0,0 +1,28 @@ +import type { CurrentUser } from "../../currentUser.js"; +import { limits } from "../../policy.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { BackToPage } from "../../ui/links.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function DeleteAccountPage(props: { user: CurrentUser; csrf: string; message?: string }) { + return ( + + } title="Delete account"> + {props.message} + + + + + + + + + + + + + + + ); +} diff --git a/src/views/account/favorites.tsx b/src/views/account/favorites.tsx new file mode 100644 index 0000000..4686e19 --- /dev/null +++ b/src/views/account/favorites.tsx @@ -0,0 +1,25 @@ +import type { PersonCard } from "../../models.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { PersonActionCard } from "../../ui/people.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function FavoritesPage(props: { user: CurrentUser; csrf: string; people: PersonCard[] }) { + return ( + + +

    Click on Add to favorites on any profile to add a user to this list.

    + {props.people.length ? props.people.map((person) => ( + +
    + + + + +
    + )) :

    You haven't added any users to your favorites yet.

    } +
    +
    + ); +} diff --git a/src/views/account/index.tsx b/src/views/account/index.tsx new file mode 100644 index 0000000..97657ee --- /dev/null +++ b/src/views/account/index.tsx @@ -0,0 +1,4 @@ +export { DeleteAccountPage } from "./deleteAccount.js"; +export { FavoritesPage } from "./favorites.js"; +export { PropsPage } from "./props.js"; +export { SettingsPage } from "./settings.js"; diff --git a/src/views/account/props.tsx b/src/views/account/props.tsx new file mode 100644 index 0000000..f31ffed --- /dev/null +++ b/src/views/account/props.tsx @@ -0,0 +1,20 @@ +import type { BlogListItem, PostItem } from "../../models.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; +import { BlogCardList } from "../blogs/index.js"; +import { PostList } from "../posts/index.js"; + +export function PropsPage(props: { user: CurrentUser; csrf: string; posts: PostItem[]; blogs: BlogListItem[] }) { + return ( + }> + +

    Posts and blog entries you give props to show up here.

    +

    Posts

    + +

    Blog entries

    + +
    +
    + ); +} diff --git a/src/views/account/settings.tsx b/src/views/account/settings.tsx new file mode 100644 index 0000000..1f098d0 --- /dev/null +++ b/src/views/account/settings.tsx @@ -0,0 +1,143 @@ +import type { UserProfile } from "../../models.js"; +import { + notificationPreferenceTypes, + type NotificationPreferences, + type NotificationPreferenceType +} from "../../notifications.js"; +import { limits } from "../../policy.js"; +import { supportedTimeZones, timeZoneOptionLabel } from "../../timeZones.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { Panel } from "../../ui/panels.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function SettingsPage(props: { + user: CurrentUser; + csrf: string; + notificationPreferences: NotificationPreferences; + profile: UserProfile; + message?: string; +}) { + return ( + + + {props.message} + + + + + + + + + + + + + + + ); +} + +const notificationPreferenceOptions: Array<{ label: string; type: NotificationPreferenceType }> = [ + { label: "Wall posts", type: notificationPreferenceTypes.wallPosts }, + { label: "Comments", type: notificationPreferenceTypes.comments }, + { label: "Props", type: notificationPreferenceTypes.props }, + { label: "Favorites", type: notificationPreferenceTypes.favorites }, + { label: "Friend accepts", type: notificationPreferenceTypes.friendAccepts } +]; + +function NotificationPreferencesPanel({ preferences }: { preferences: NotificationPreferences }) { + return ( + +
    + {notificationPreferenceOptions.map((option) => ( + + ))} +
    +
    + ); +} + +function BasicDetailsPanel({ user }: { user: CurrentUser }) { + return ( + +
    + + + +
    +
    + ); +} + +function TimeZonePanel({ user }: { user: CurrentUser }) { + return ( + + + + + + ); +} + +function PasswordPanel() { + return ( + +
    + + + + + + + + + +
    +
    + ); +} + +function PrivacyPanel({ profile }: { profile: UserProfile }) { + return ( + +
    + + + +

    + Private profiles are visible to you, friends, and staff. Blog entries also use their own privacy setting; groups and skins stay + outside profile privacy. +

    +
    +
    + ); +} + +function SettingsLinks() { + return ( + <> +

    More options

    + + + ); +} diff --git a/src/views/auth/index.ts b/src/views/auth/index.ts new file mode 100644 index 0000000..4b78c00 --- /dev/null +++ b/src/views/auth/index.ts @@ -0,0 +1,4 @@ +export { LogoutPage } from "./logout.js"; +export { ResetApplyPage, ResetRequestPage, ResetUnavailablePage } from "./reset.js"; +export { SignUpPage } from "./signup.js"; +export { VerifyPage } from "./verify.js"; diff --git a/src/views/auth/logout.tsx b/src/views/auth/logout.tsx new file mode 100644 index 0000000..10a9390 --- /dev/null +++ b/src/views/auth/logout.tsx @@ -0,0 +1,16 @@ +import type { CurrentUser } from "../../currentUser.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function LogoutPage(props: { user: CurrentUser; csrf: string }) { + return ( + + +
    + + + +
    +
    + ); +} diff --git a/src/views/auth/reset.tsx b/src/views/auth/reset.tsx new file mode 100644 index 0000000..73741f2 --- /dev/null +++ b/src/views/auth/reset.tsx @@ -0,0 +1,55 @@ +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack, FormSuccess } from "../../ui/forms.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function ResetRequestPage(props: { user: CurrentUser | null; csrf: string; message?: string }) { + return ( + + + {props.message} + + + + + + + + + + + + ); +} + +export function ResetUnavailablePage(props: { user: CurrentUser | null }) { + return ( + + +

    Password reset email delivery is not available on this instance. Contact an administrator for account help.

    +
    +
    + ); +} + +export function ResetApplyPage(props: { user: CurrentUser | null; csrf: string; token: string; message?: string }) { + return ( + + + {props.message} + + + + + + + + + + + + + + + ); +} diff --git a/src/views/auth/signup.tsx b/src/views/auth/signup.tsx new file mode 100644 index 0000000..f59c5d2 --- /dev/null +++ b/src/views/auth/signup.tsx @@ -0,0 +1,50 @@ +import type { CurrentUser } from "../../currentUser.js"; +import { characterRangeLabel, limits } from "../../policy.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { Panel } from "../../ui/panels.js"; + +export function SignUpPage(props: { user: CurrentUser | null; csrf: string; initialEmail?: string; initialHandle?: string; message?: string }) { + return ( + + + +
      +
    • Custom profiles and wall posts
    • +
    • Blogs and groups
    • +
    • No tracking, ads, or feed algorithm
    • +
    • Open source and self-hostable
    • +
    +
    + {props.message} + + + + + + + + + + + + + + + + + + + + + + +
    +
    + ); +} diff --git a/src/views/auth/verify.tsx b/src/views/auth/verify.tsx new file mode 100644 index 0000000..d3f094c --- /dev/null +++ b/src/views/auth/verify.tsx @@ -0,0 +1,13 @@ +import type { CurrentUser } from "../../currentUser.js"; +import { FormError, FormSuccess } from "../../ui/forms.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function VerifyPage(props: { user: CurrentUser | null; message: string; success: boolean }) { + return ( + + + {props.success ? {props.message} : {props.message}} + + + ); +} diff --git a/src/views/blogs/cards.tsx b/src/views/blogs/cards.tsx new file mode 100644 index 0000000..3839866 --- /dev/null +++ b/src/views/blogs/cards.tsx @@ -0,0 +1,38 @@ +import type { CurrentUser } from "../../currentUser.js"; +import type { BlogListItem } from "../../models.js"; +import { blogPath, profilePath } from "../../paths.js"; +import { defaultBlogCategory } from "../../policy.js"; +import { plainTextFromHtml } from "../../server/security/html.js"; +import { Layout, PageFrame, type PageSeo } from "../../shell/index.js"; +import { truncateText } from "../../text.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; + +export function BlogListPage(props: { user: CurrentUser | null; title: string; blogs: BlogListItem[]; seo?: PageSeo }) { + return ( + + + {props.user ?

    [Create a new blog entry]

    : null} + +
    +
    + ); +} + +export function BlogCardList(props: { blogs: BlogListItem[]; empty: string }) { + return ( + <> + {props.blogs.length ? props.blogs.map((blog) => ) :

    {props.empty}

    } + + ); +} + +function BlogCard(props: { blog: BlogListItem }) { + const blog = props.blog; + return ( +
    +

    {blog.title} {blog.category ?? defaultBlogCategory}

    + {blog.username && blog.authorHandle ?

    By {blog.username}

    : null} +

    {truncateText(plainTextFromHtml(blog.bodyHtml), 180)}

    +
    + ); +} diff --git a/src/views/blogs/editor.tsx b/src/views/blogs/editor.tsx new file mode 100644 index 0000000..b5c9561 --- /dev/null +++ b/src/views/blogs/editor.tsx @@ -0,0 +1,116 @@ +import type { BlogItem } from "../../models.js"; +import { blogCategories, defaultBlogCategory, limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { blogBodyTextFromHtml } from "../../server/security/html.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { Layout, SplitLayout, SplitPane } from "../../shell/index.js"; +import { blogPath } from "../../paths.js"; + +export function NewBlogPage(props: { user: CurrentUser; csrf: string; message?: string }) { + return ; +} + +export function EditBlogPage(props: { user: CurrentUser; csrf: string; blog: BlogItem; message?: string }) { + return ; +} + +function BlogEditorPage(props: { + user: CurrentUser; + csrf: string; + action: string; + blog?: BlogItem; + message?: string; + submitLabel: string; + title: string; +}) { + const blog = props.blog; + return ( + + + +
    + +
    +
    + +

    {props.title}

    + {props.message} + + + + + + + + + + + +
    +
    +
    + ); +} + +function BlogBodyField(props: { value?: string }) { + return ( + + + + ); +} + +function BlogFormattingHelp() { + return ( +
    +

    Formatting

    +

    Write normally. Line breaks become line breaks.

    +
    + [h2]Section[/h2] + [h3]Small section[/h3] + [b]bold[/b] + [i]italic[/i] + [u]underline[/u] + [code]inline code[/code] + [quote]quoted text[/quote] + [url]https://example.com[/url] + [url=https://example.com]label[/url] + [list] [*] one [*] two [/list] + [olist] [*] first [*] second [/olist] +
    +
    + ); +} + +function BlogOptions(props: { blog?: BlogItem }) { + const blog = props.blog; + return ( +
    + + + + + + +
    + + {blog ? : null} +
    +
    + ); +} diff --git a/src/views/blogs/entry.tsx b/src/views/blogs/entry.tsx new file mode 100644 index 0000000..fe60589 --- /dev/null +++ b/src/views/blogs/entry.tsx @@ -0,0 +1,125 @@ +import type { BlogItem, CommentItem } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import { canModerateTarget, isAdminUser } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionBar, ActionLabel } from "../../ui/actions.js"; +import { CommentPanel } from "../../ui/comments.js"; +import { UserContent } from "../../ui/userContent.js"; +import { PropAction } from "../../ui/engagement.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { Icon } from "../../ui/icons.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { blogCommentsPath, blogPath, profileBlogPath, profilePath, reportPath } from "../../paths.js"; +import { absoluteUrl } from "../../server/indexing/urls.js"; +import { plainTextFromHtml } from "../../server/security/html.js"; +import { seoText, type PageSeo } from "../../settings/seo.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; +import { Layout, SplitLayout, SplitPane } from "../../shell/index.js"; + +export function BlogEntryPage(props: { + user: CurrentUser | null; + csrf: string; + blog: BlogItem; + comments: CommentItem[]; + commentsHref?: string | null; + fullComments?: boolean; +}) { + const canEdit = props.user?.id === props.blog.authorId || isAdminUser(props.user); + const canDelete = canEdit || Boolean(props.user && canModerateTarget(props.user, { id: props.blog.authorId, role: props.blog.authorRole })); + const href = blogPath(props.blog); + const engagementActions = props.user ? ( + + ) : null; + const managementActions = canDelete ? ( + <> + {canEdit ? Edit : null} +
    + + + + + ) : null; + return ( + } seo={blogSeo(props.blog)}> + + +
    +
    +

    Published by {props.blog.username}

    + +

    Category: {props.blog.category}

    + {props.user ? null :

    Props: {props.blog.propsCount}

    } +

    + + Report + +

    +
    +
    +
    + +

    {props.blog.title}

    + {engagementActions || managementActions ? : null} + + {props.blog.commentsEnabled ? ( + + ) :

    Comments are disabled for this entry.

    } +
    +
    +
    + ); +} + +function blogSeo(blog: BlogItem): PageSeo { + if (blog.privacyLevel !== 0) return { noindex: true }; + + const path = blogPath(blog); + const description = seoText(plainTextFromHtml(blog.bodyHtml), 180); + const authorUrl = absoluteUrl(profilePath(blog.authorHandle)); + return { + canonicalPath: path, + description, + jsonLd: { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: blog.title, + description, + url: absoluteUrl(path), + mainEntityOfPage: absoluteUrl(path), + datePublished: blog.createdAt, + dateModified: blog.updatedAt, + author: { + "@type": "Person", + name: blog.username, + url: authorUrl + } + }, + modifiedTime: blog.updatedAt, + publishedTime: blog.createdAt, + title: blog.title, + type: "article" + }; +} diff --git a/src/views/blogs/index.ts b/src/views/blogs/index.ts new file mode 100644 index 0000000..3d2de9c --- /dev/null +++ b/src/views/blogs/index.ts @@ -0,0 +1,3 @@ +export { BlogCardList, BlogListPage } from "./cards.js"; +export { BlogEntryPage } from "./entry.js"; +export { EditBlogPage, NewBlogPage } from "./editor.js"; diff --git a/src/views/groups/index.tsx b/src/views/groups/index.tsx new file mode 100644 index 0000000..e74d4cd --- /dev/null +++ b/src/views/groups/index.tsx @@ -0,0 +1,123 @@ +import type { GroupItem, GroupMember, PostItem } from "../../models.js"; +import { limits, systemIds } from "../../policy.js"; +import { canModerateTarget, isAdminUser } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { userTextFromHtml } from "../../server/security/html.js"; +import { ActionBar, ActionLabel } from "../../ui/actions.js"; +import { GroupSummaryCard } from "../../ui/groups.js"; +import { BackLink, BackToPage } from "../../ui/links.js"; +import { GroupPostBox } from "../posts/index.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; +import { UserContent } from "../../ui/userContent.js"; +import { CharacterLimitHint, CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { PeopleBox } from "../../ui/people.js"; +import { groupPath, profilePath } from "../../paths.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function GroupListPage(props: { user: CurrentUser; groups: GroupItem[] }) { + return ( + + +

    [Create a new group]

    + {props.groups.length ? props.groups.map((group) => ) :

    No groups have been created... (yet)

    } +
    +
    + ); +} + +export function GroupFormPage(props: { user: CurrentUser; csrf: string; group?: GroupItem; message?: string }) { + const group = props.group; + const action = group ? `${groupPath(group)}/edit` : "/groups/new"; + return ( + + : } + title={group ? "Edit group" : "Create group"} + > + {props.message} + + + + + + + + + }> + + + + + + ); +} + +type GroupPageProps = { + user: CurrentUser; + csrf: string; + group: GroupItem; + posts: PostItem[]; + members: GroupMember[]; + isMember: boolean; + fullPosts?: boolean; + postsNextHref?: string | null; + postsResetHref?: string | null; + postsViewAllHref?: string | null; +}; + +export function GroupPage(props: GroupPageProps) { + const groupHref = groupPath(props.group); + const protectedGroup = props.group.id === systemIds.defaultGroupId; + const isOwner = props.user.id === props.group.ownerId; + const canManage = isOwner || isAdminUser(props.user); + const canDelete = !protectedGroup && (canManage || canModerateTarget(props.user, { id: props.group.ownerId, role: props.group.ownerRole })); + const membershipAction = !protectedGroup && !isOwner ? ( +
    + + + + ) : null; + const managementActions = canDelete ? ( + <> + {canManage ? Edit : null} +
    + + + + + ) : null; + const groupActions = membershipAction || managementActions ? ( + {membershipAction}{managementActions}} /> + ) : null; + return ( + }> + : } + title={props.group.name} + > +

    Owner: {props.group.ownerName}

    + {groupActions} + + + {props.members.length} {props.members.length === 1 ? "member" : "members"}.

    } + /> +
    +
    + ); +} diff --git a/src/views/home/dashboard.tsx b/src/views/home/dashboard.tsx new file mode 100644 index 0000000..901adc2 --- /dev/null +++ b/src/views/home/dashboard.tsx @@ -0,0 +1,92 @@ +import type { BlogListItem, GroupItem, PersonCard, PostItem, UserProfile } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import type { SiteSettings } from "../../settings/site.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { Icon } from "../../ui/icons.js"; +import { Panel } from "../../ui/panels.js"; +import { ProfileImage } from "../../ui/avatars.js"; +import { CommunityBox } from "../../ui/groups.js"; +import { PeopleBox } from "../../ui/people.js"; +import { blogPath, profileBlogPath, profileFriendsPath, profilePath } from "../../paths.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; +import { PreviewTitleLink } from "../../ui/previews.js"; +import { InlineLinks } from "../../ui/links.js"; +import { Layout, SplitLayout, SplitPane } from "../../shell/index.js"; +import { PostList } from "../posts/index.js"; +import { AnnouncementBox, SourceBox } from "./infoPanels.js"; + +export function HomePage(props: { + user: CurrentUser; + csrf: string; + settings: SiteSettings; + profile: UserProfile; + newest: PersonCard[]; + newestGroups: GroupItem[]; + friendCount: number; + pending: PersonCard[]; + blogs: BlogListItem[]; + feedPosts: PostItem[]; + feedHref?: string | null; + newestHref?: string | null; + newestGroupsHref?: string | null; +}) { + const publicProfilePath = profilePath(props.profile); + return ( + }> + + + Hello, {props.profile.username}!} headingLevel="h1"> +
    + +
    + +
    + +

    + My URL: {publicProfilePath} +

    +
    +
    + +

    Your friends: {props.friendCount}

    +

    Profile views: {props.profile.views}

    +
    + + +
    + + [view all] : null} tone="soft"> + + + [New entry]} tone="soft"> + {props.blogs.length ? props.blogs.map((entry) => ( +

    + )) :

    There are no blog entries yet.

    } +
    + + + +
    +
    +
    + ); +} diff --git a/src/views/home/featuredPeople.ts b/src/views/home/featuredPeople.ts new file mode 100644 index 0000000..4d69b53 --- /dev/null +++ b/src/views/home/featuredPeople.ts @@ -0,0 +1,18 @@ +import type { PersonCard } from "../../models.js"; +import { limits } from "../../policy.js"; +import type { PeopleBoxPerson } from "../../ui/people.js"; + +// Intentional landing-page easter egg, not seed or demo data. +const tomFromMyspace = { + id: -1, + username: "Tom", + handle: "tom", + pfp: "tom.webp", + href: null, + imageSrc: "/static/media/tom.webp" +} satisfies PeopleBoxPerson; + +export function coolNewPeople(people: PersonCard[], admin: PersonCard | null = null) { + const newest = people.filter((person) => person.handle !== tomFromMyspace.handle && (!admin || person.id !== admin.id)); + return [tomFromMyspace, ...(admin ? [admin] : []), ...newest].slice(0, limits.newestPeople); +} diff --git a/src/views/home/index.ts b/src/views/home/index.ts new file mode 100644 index 0000000..c2717e4 --- /dev/null +++ b/src/views/home/index.ts @@ -0,0 +1,2 @@ +export { HomePage } from "./dashboard.js"; +export { LandingPage } from "./landing.js"; diff --git a/src/views/home/infoPanels.tsx b/src/views/home/infoPanels.tsx new file mode 100644 index 0000000..c06ddec --- /dev/null +++ b/src/views/home/infoPanels.tsx @@ -0,0 +1,91 @@ +import { sourceProject, sourceProjectDetailsLabel } from "../../project.js"; +import type { SiteSettings } from "../../settings/site.js"; +import { sanitizeLinkedText } from "../../server/security/html.js"; +import { Panel } from "../../ui/panels.js"; +import { SourceProjectPoweredBySentence } from "../../ui/sourceProject.js"; +import { UserContent } from "../../ui/userContent.js"; + +export const landingCards = [ + { + title: "Open source", + body: "The code is public, with a license that lets people inspect, change, run, and share it.", + href: "/source", + cta: "Inspect the code" + }, + { + title: "No tracking or ads", + body: "No built-in analytics pixels, ad networks, or personalized feed algorithm. Browse people and links directly.", + href: "/privacy", + cta: "See privacy details" + }, + { + title: "Custom profiles", + body: "Make your profile feel like your own, with a wall, friends, groups, blogs, messages, theme songs, and playful skins.", + href: "/skins", + cta: "Browse skins" + }, + { + title: "Small and lightning fast", + body: "One Node process, SQLite, local uploads, server-rendered pages, and no required third-party runtime services.", + href: "/source", + cta: "View the stack" + }, + { + title: "Easy to manage", + body: "Admin accounts can change branding, home copy, moderation, automod, rate limits, users, and email without touching code.", + href: `${sourceProject.repositoryUrl}/blob/main/docs/self-hosting.md`, + cta: "Read admin docs" + }, + { + title: "Tiny hosting bill", + body: "A Hetzner CX23 VPS costs about $4.99/month for a 50k-member community, or run it on a server you already own with no new hosting bill.", + href: "https://www.hetzner.com/cloud/cost-optimized", + cta: "Check VPS pricing" + }, + { + title: "Light by default", + body: "Simple server-rendered pages stay quick on low-powered phones, slow laptops, and low-data connections, with no required client-side JavaScript.", + href: "/about", + cta: "Meet the project" + }, + { + title: "Safer communities", + body: "Automod, reports, user blocks, bans, rate limits, and moderator accounts give communities practical tools to keep things under control.", + href: "/rules", + cta: "Review community rules" + } +]; + +export function InfoCard(props: { title: string; body: string; href: string; cta: string }) { + return ( +
    +

    {props.title}

    +

    {props.body}

    + +
    + ); +} + +export function AnnouncementBox({ settings }: { settings: SiteSettings }) { + if (!settings.home.announcement) return null; + return ( + + + + ); +} + +export function SourceBox() { + return ( + + ); +} diff --git a/src/views/home/landing.tsx b/src/views/home/landing.tsx new file mode 100644 index 0000000..3f2337f --- /dev/null +++ b/src/views/home/landing.tsx @@ -0,0 +1,68 @@ +import type { GroupItem, PersonCard } from "../../models.js"; +import type { SiteSettings } from "../../settings/site.js"; +import { limits } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { CommunityBox } from "../../ui/groups.js"; +import { Panel } from "../../ui/panels.js"; +import { PeopleBox } from "../../ui/people.js"; +import { Layout, SplitLayout, SplitPane } from "../../shell/index.js"; +import { coolNewPeople } from "./featuredPeople.js"; +import { AnnouncementBox, InfoCard, landingCards, SourceBox } from "./infoPanels.js"; + +export function LandingPage(props: { + user: CurrentUser | null; + csrf: string; + settings: SiteSettings; + admin: PersonCard | null; + newest: PersonCard[]; + newestGroups: GroupItem[]; + message?: string; + passwordResetAvailable?: boolean; +}) { + const welcomeText = props.settings.home.welcomeText.trim(); + + return ( + + + + + + + + + {welcomeText ? ( +
    +

    {welcomeText}

    +
    + ) : null} + + {props.message} + + + + + + + + + + + Sign up + + + {props.passwordResetAvailable ? Forgot your password? : null} + + +
    +
    +
    + {landingCards.map((info) => )} +
    +
    + ); +} + +function coolNewCommunities(groups: GroupItem[]) { + return groups.slice(0, limits.newestCommunities).map((group) => ({ ...group, href: null })); +} diff --git a/src/views/messages/compose.tsx b/src/views/messages/compose.tsx new file mode 100644 index 0000000..25c5000 --- /dev/null +++ b/src/views/messages/compose.tsx @@ -0,0 +1,33 @@ +import { newMessagePath } from "../../paths.js"; +import { limits } from "../../policy.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import type { NewMessagePageProps } from "./types.js"; + +export function NewMessagePage(props: NewMessagePageProps) { + const title = props.title ?? "Send message"; + const recipientValue = props.recipientInput ?? props.recipient?.handle ?? ""; + return ( + + + {props.message} + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/views/messages/conversations.tsx b/src/views/messages/conversations.tsx new file mode 100644 index 0000000..31726f4 --- /dev/null +++ b/src/views/messages/conversations.tsx @@ -0,0 +1,69 @@ +import type { MessageConversation } from "../../models.js"; +import { messageConversationPath } from "../../paths.js"; +import { ProfileImage } from "../../ui/avatars.js"; +import { CountBadge } from "../../ui/badges.js"; +import { classNames } from "../../ui/classes.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { Panel } from "../../ui/panels.js"; +import { LocalizedTime } from "../../ui/time.js"; + +export function ConversationList(props: { + conversations: MessageConversation[]; + currentCursor?: string | null; + currentHandle?: string; + nextHref?: string | null; + resetHref?: string | null; + viewerId: number; +}) { + return ( + + {props.conversations.length ? ( + + ) : ( +

    No conversations yet.

    + )} + +
    + ); +} + +function ConversationLink(props: { + conversation: MessageConversation; + current: boolean; + currentCursor?: string | null; + viewerId: number; +}) { + const unread = props.conversation.unreadCount > 0 && !props.current; + return ( + + + + + {props.conversation.otherName} + {unread ? : null} + + {props.conversation.latestSenderId === props.viewerId ? "You: " : ""}{props.conversation.latestSubject} + + + + ); +} diff --git a/src/views/messages/index.ts b/src/views/messages/index.ts new file mode 100644 index 0000000..6773a6c --- /dev/null +++ b/src/views/messages/index.ts @@ -0,0 +1,2 @@ +export { NewMessagePage } from "./compose.js"; +export { MessagesPage } from "./page.js"; diff --git a/src/views/messages/page.tsx b/src/views/messages/page.tsx new file mode 100644 index 0000000..81f0c65 --- /dev/null +++ b/src/views/messages/page.tsx @@ -0,0 +1,44 @@ +import { newMessagePath } from "../../paths.js"; +import { Layout, PageFrame, SplitLayout, SplitPane } from "../../shell/index.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { ConversationList } from "./conversations.js"; +import { ConversationThread } from "./thread.js"; +import type { MessagesPageProps } from "./types.js"; + +export function MessagesPage(props: MessagesPageProps) { + return ( + + New message} + > + + + + + + + + + + + ); +} diff --git a/src/views/messages/thread.tsx b/src/views/messages/thread.tsx new file mode 100644 index 0000000..67d6ece --- /dev/null +++ b/src/views/messages/thread.tsx @@ -0,0 +1,116 @@ +import type { MessageItem } from "../../models.js"; +import { messageFormContexts } from "../../messages.js"; +import { messageDeletePath, newMessagePath, profilePath, reportPath } from "../../paths.js"; +import { limits } from "../../policy.js"; +import { ActionBar, ActionLabel } from "../../ui/actions.js"; +import { ProfileImageLink } from "../../ui/avatars.js"; +import { classNames } from "../../ui/classes.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { Panel } from "../../ui/panels.js"; +import { LocalizedTime } from "../../ui/time.js"; +import { UserContent } from "../../ui/userContent.js"; +import type { MessageParticipant } from "./types.js"; + +export function ConversationThread(props: { + csrf: string; + formMessage?: string; + messages: MessageItem[]; + nextHref?: string | null; + replyBody?: string; + replySubject?: string; + resetHref?: string | null; + selected?: MessageParticipant; + viewerId: number; +}) { + if (!props.selected) { + return ( + +

    Choose a conversation or start a new message.

    +
    + ); + } + + const orderedMessages = [...props.messages].reverse(); + return ( + Conversation with {props.selected.username}} + bodyClassName="message-thread-panel" + > +
    + {orderedMessages.length ? orderedMessages.map((message) => ( + + )) :

    No messages in this conversation.

    } +
    + + +
    + ); +} + +function MessageEntry(props: { csrf: string; message: MessageItem; viewerId: number }) { + const own = props.message.senderId === props.viewerId; + const author = { handle: props.message.senderHandle, name: props.message.senderName, pfp: props.message.senderPfp }; + const utilityActions = ( + <> + Report +
    + + + + + ); + return ( +
    +
    + + +
    + + +
    + ); +} + +function ReplyForm(props: { body?: string; csrf: string; message?: string; recipient: MessageParticipant; subject?: string }) { + return ( +
    +

    Reply

    + {props.message} + + + + + + + + + + + + +
    + ); +} diff --git a/src/views/messages/types.ts b/src/views/messages/types.ts new file mode 100644 index 0000000..2539ede --- /dev/null +++ b/src/views/messages/types.ts @@ -0,0 +1,36 @@ +import type { CurrentUser } from "../../currentUser.js"; +import type { MessageConversation, MessageItem } from "../../models.js"; + +export type MessageParticipant = { + id: number; + username: string; + handle: string; + pfp: string; +}; + +export type NewMessagePageProps = { + user: CurrentUser; + csrf: string; + recipient?: MessageParticipant; + recipientInput?: string; + subject?: string; + body?: string; + title?: string; + message?: string; +}; + +export type MessagesPageProps = { + user: CurrentUser; + csrf: string; + conversations: MessageConversation[]; + conversationsBefore?: string | null; + conversationsNextHref?: string | null; + conversationsResetHref?: string | null; + selected?: MessageParticipant; + threadMessages: MessageItem[]; + threadNextHref?: string | null; + threadResetHref?: string | null; + formMessage?: string; + replySubject?: string; + replyBody?: string; +}; diff --git a/src/views/notifications/index.tsx b/src/views/notifications/index.tsx new file mode 100644 index 0000000..10ffbc5 --- /dev/null +++ b/src/views/notifications/index.tsx @@ -0,0 +1,136 @@ +import type { CurrentUser } from "../../currentUser.js"; +import type { NotificationItem } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import { commentNotificationKinds, notificationKinds, notificationTextByKind } from "../../notifications.js"; +import { blogCommentsPath, blogPath, postPath, profilePath, skinCommentsPath, skinPath } from "../../paths.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { ActorSummary, type ActorSummaryItem } from "../../ui/actors.js"; +import { classNames } from "../../ui/classes.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { LocalizedTime } from "../../ui/time.js"; + +type NotificationGroup = { + actors: ActorSummaryItem[]; + latest: NotificationItem; + unread: boolean; +}; + +export function NotificationsPage(props: { + user: CurrentUser; + csrf: string; + notifications: NotificationItem[]; + unreadCount: number; + nextHref?: string | null; + resetHref?: string | null; +}) { + const groups = notificationGroups(props.notifications); + return ( + + + {props.unreadCount ? ( +
    +

    {props.unreadCount} new {props.unreadCount === 1 ? "notification" : "notifications"}

    +
    + + + +
    + ) : null} + {groups.length ? ( +
    + {groups.map((group) => )} +
    + ) : ( +

    No notifications yet.

    + )} + +
    +
    + ); +} + +function NotificationCard({ group }: { group: NotificationGroup }) { + const href = notificationHref(group.latest); + return ( + + ); +} + +function notificationGroups(items: NotificationItem[]) { + const groups = new Map(); + for (const item of items) { + const key = notificationGroupKey(item); + let group = groups.get(key); + if (!group) { + group = { actors: [], latest: item, unread: false }; + groups.set(key, group); + } + group.unread ||= !item.readAt; + addNotificationActor(group, item); + } + return [...groups.values()]; +} + +function addNotificationActor(group: NotificationGroup, item: NotificationItem) { + if (group.actors.some((actor) => actor.id === item.actorId)) return; + group.actors.push({ + id: item.actorId, + handle: item.actorHandle, + name: item.actorName + }); +} + +function notificationGroupKey(item: NotificationItem) { + if (item.kind === notificationKinds.favorite) return `${item.kind}:favorites`; + if (item.kind === notificationKinds.wallPost) return `${item.kind}:${item.subjectId}`; + if (item.kind === notificationKinds.friendAccepted) return `${item.kind}:${item.actorId}`; + return `${item.kind}:${item.contextType}:${item.contextId}`; +} + +function notificationText(item: NotificationItem) { + if ( + item.kind === notificationKinds.postComment && + item.contextPostWallUserId === item.recipientId && + item.contextPostAuthorId !== item.recipientId + ) { + return "commented on a post on your wall."; + } + return notificationTextByKind[item.kind]; +} + +function notificationHref(item: NotificationItem) { + const commentAnchor = commentNotificationKinds.has(item.kind) ? `#${anchors.comment(item.subjectId)}` : ""; + switch (item.contextType) { + case "blog": + return commentAnchor ? `${blogCommentsPath(item.contextId)}${commentAnchor}` : blogPath(item.contextId); + case "post": + return `${postPath(item.contextId)}${commentAnchor}`; + case "skin": + return commentAnchor ? `${skinCommentsPath(item.contextId)}${commentAnchor}` : skinPath(item.contextId); + case "user": + return profilePath(item.actorHandle); + } +} + +function notificationContextLabel(item: NotificationItem) { + switch (item.contextType) { + case "blog": + return item.contextTitle ? `Blog: ${item.contextTitle}` : "View blog entry"; + case "post": + return "View post"; + case "skin": + return item.contextTitle ? `Skin: ${item.contextTitle}` : "View skin"; + case "user": + return "View profile"; + } +} diff --git a/src/views/people/index.tsx b/src/views/people/index.tsx new file mode 100644 index 0000000..dfbadba --- /dev/null +++ b/src/views/people/index.tsx @@ -0,0 +1,76 @@ +import type { PersonCard } from "../../models.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { PeopleBox, PersonActionCard } from "../../ui/people.js"; +import { Layout, PageFrame, type PageSeo } from "../../shell/index.js"; + +export function PeoplePage(props: { + user: CurrentUser | null; + title: string; + people: PersonCard[]; + nextHref?: string | null; + resetHref?: string | null; + seo?: PageSeo; +}) { + return ( + + + + + + + ); +} + +export function RequestsPage(props: { user: CurrentUser; csrf: string; received: PersonCard[]; sent: PersonCard[] }) { + return ( + + +

    {props.received.length} open friend requests

    + {props.received.length ? props.received.map((person) => ( + +
    + + + + + +
    + )) :

    No open friend requests.

    } +
    +

    Sent requests

    +

    {props.sent.length} open friend requests

    + {props.sent.length ? props.sent.map((person) => ( + +
    + + + + +
    + )) :

    You have no pending sent friend requests.

    } +
    +
    + ); +} + +export function BlocksPage(props: { user: CurrentUser; csrf: string; people: PersonCard[] }) { + return ( + + + {props.people.length ? props.people.map((person) => ( + +
    + + + + + +
    + )) :

    You have not blocked any users.

    } +
    +
    + ); +} diff --git a/src/views/posts/cards.tsx b/src/views/posts/cards.tsx new file mode 100644 index 0000000..3e009c5 --- /dev/null +++ b/src/views/posts/cards.tsx @@ -0,0 +1,146 @@ +import type { PostItem } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import { canModerateTarget } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionBar, ActionLabel } from "../../ui/actions.js"; +import { ActorSummary } from "../../ui/actors.js"; +import { UserContent } from "../../ui/userContent.js"; +import { PropAction, PropCount } from "../../ui/engagement.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { ProfileImageLink } from "../../ui/avatars.js"; +import { AuthorSkinBoundary, profileSkinPart } from "../../skins/rendering.js"; +import { groupPath, postImagePath, postPath, profilePath, reportPath } from "../../paths.js"; +import { LocalizedTime } from "../../ui/time.js"; + +export type PostSkinSource = "author" | "surrounding-profile"; + +export function PostList(props: { + skinSource?: PostSkinSource; + user: CurrentUser | null; + csrf: string; + posts: PostItem[]; + empty: string; + canInteract?: boolean; +}) { + return ( +
    + {props.posts.length ? props.posts.map((post) => ( + + )) :

    {props.empty}

    } +
    + ); +} + +export function PostCard(props: { + skinSource?: PostSkinSource; + user: CurrentUser | null; + csrf: string; + post: PostItem; + canInteract?: boolean; +}) { + const post = props.post; + const canDelete = canDeletePost(props.user, post); + const canInteract = props.canInteract ?? Boolean(post.viewerCanInteract); + const href = postPath(post); + const authorSkinHtml = (props.skinSource ?? "author") === "author" ? post.authorSkinHtml : null; + const engagementActions = ( + <> + {props.user && canInteract ? ( + + ) : ( + + )} + {post.commentCount} {post.commentCount === 1 ? "comment" : "comments"} + + ); + const utilityActions = ( + <> + Report + {canDelete ? ( +
    + + + + ) : null} + + ); + return ( + +
    + +
    + +
    +

    + {post.username}{contextLabel(post)} +

    + +
    +
    + + {post.mediaFilename ? : null} + +
    +
    + ); +} + +function CommentBumpLabel({ post, href }: { post: PostItem; href: string }) { + const bump = post.commentBump; + if (!bump?.commentedAt || !bump.actors.length || bump.commenterCount < 1) return null; + + const hiddenCount = Math.max(0, bump.commenterCount - bump.actors.length); + return ( +

    + commented on this post{" "} + {" "} + +

    + ); +} + +function PostImage({ filename }: { filename: string }) { + return post image; +} + +function contextLabel(post: PostItem) { + if (post.groupId && post.groupName) return <> in {post.groupName}; + if (post.wallUserId && post.wallUsername && post.wallUserHandle && post.wallUserId !== post.authorId) { + return <> on {post.wallUsername}'s wall; + } + return null; +} + +function canDeletePost(user: CurrentUser | null, post: PostItem) { + return Boolean( + user && + (user.id === post.authorId || + user.id === post.wallUserId || + user.id === post.groupOwnerId || + canModerateTarget(user, { id: post.authorId, role: post.authorRole })) + ); +} + +function postCanInteract(post: PostItem, override: boolean | undefined) { + return override ?? Boolean(post.viewerCanInteract); +} diff --git a/src/views/posts/comments.tsx b/src/views/posts/comments.tsx new file mode 100644 index 0000000..12843dc --- /dev/null +++ b/src/views/posts/comments.tsx @@ -0,0 +1,29 @@ +import type { CommentItem, PostItem } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { CommentForm, CommentList } from "../../ui/comments.js"; +import { Panel } from "../../ui/panels.js"; +import { postCommentsPath } from "../../paths.js"; + +export function PostComments(props: { user: CurrentUser; csrf: string; post: PostItem; comments: CommentItem[]; canInteract: boolean }) { + return ( + + {props.canInteract ? ( + + ) :

    Join the group to comment.

    } + +
    + ); +} + +function postCommentDeleteOwnerIds(post: PostItem) { + return [post.authorId, post.wallUserId, post.groupOwnerId].filter((id): id is number => id !== null); +} diff --git a/src/views/posts/composer.tsx b/src/views/posts/composer.tsx new file mode 100644 index 0000000..dad07ba --- /dev/null +++ b/src/views/posts/composer.tsx @@ -0,0 +1,19 @@ +import { limits, mediaAccept } from "../../policy.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CharacterLimitHint, CsrfInput } from "../../ui/forms.js"; + +export function PostComposer(props: { action: string; csrf: string; button: string }) { + return ( +
    + + +
    +
    + + +
    + +
    + + ); +} diff --git a/src/views/posts/index.ts b/src/views/posts/index.ts new file mode 100644 index 0000000..2c4660a --- /dev/null +++ b/src/views/posts/index.ts @@ -0,0 +1,5 @@ +export { GroupPostBox, WallBox } from "./panels.js"; +export { PostCard, PostList } from "./cards.js"; +export type { PostSkinSource } from "./cards.js"; +export { PostComposer } from "./composer.js"; +export { FeedPage, PostPage } from "./pages.js"; diff --git a/src/views/posts/pages.tsx b/src/views/posts/pages.tsx new file mode 100644 index 0000000..6dc249e --- /dev/null +++ b/src/views/posts/pages.tsx @@ -0,0 +1,32 @@ +import type { CommentItem, PostItem } from "../../models.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { BackToPage } from "../../ui/links.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; +import { PostCard, PostList } from "./cards.js"; +import { PostComments } from "./comments.js"; +import { PostComposer } from "./composer.js"; + +export function FeedPage(props: { user: CurrentUser; csrf: string; posts: PostItem[]; nextHref?: string | null; resetHref?: string | null }) { + return ( + }> + + + + + + + ); +} + +export function PostPage(props: { user: CurrentUser; csrf: string; post: PostItem; comments: CommentItem[]; canInteract: boolean }) { + return ( + }> + }> + + + + + ); +} diff --git a/src/views/posts/panels.tsx b/src/views/posts/panels.tsx new file mode 100644 index 0000000..725f590 --- /dev/null +++ b/src/views/posts/panels.tsx @@ -0,0 +1,61 @@ +import type { GroupItem, PostItem, UserProfile } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { PaginationNav } from "../../ui/pagination.js"; +import { Panel } from "../../ui/panels.js"; +import { PostComposer } from "./composer.js"; +import { PostList } from "./cards.js"; +import { profileSkinPart } from "../../skins/rendering.js"; +import { groupPath, profileWallPath } from "../../paths.js"; + +export function WallBox(props: { + user: CurrentUser | null; + csrf: string; + profile: UserProfile; + posts: PostItem[]; + canPost: boolean; + fullWall?: boolean; + nextHref?: string | null; + resetHref?: string | null; + viewAllHref?: string | null; +}) { + return ( + + {props.canPost ? ( + + ) : props.user ?

    Only {props.profile.username}'s friends can post here.

    :

    Log in to post.

    } + {!props.fullWall && props.viewAllHref ?

    ( View all )

    : null} + + +
    + ); +} + +export function GroupPostBox(props: { + user: CurrentUser; + csrf: string; + group: GroupItem; + posts: PostItem[]; + isMember: boolean; + fullPosts?: boolean; + nextHref?: string | null; + resetHref?: string | null; + viewAllHref?: string | null; +}) { + return ( + + {props.isMember ? ( + + ) :

    Only group members can post, prop, or comment.

    } + {!props.fullPosts && props.viewAllHref ?

    ( View all )

    : null} + + +
    + ); +} diff --git a/src/views/profile/actions.tsx b/src/views/profile/actions.tsx new file mode 100644 index 0000000..695d481 --- /dev/null +++ b/src/views/profile/actions.tsx @@ -0,0 +1,178 @@ +import type { UserProfile } from "../../models.js"; +import { anchors } from "../../anchors.js"; +import { canModerateTarget, isAdminUser } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { newMessagePath, reportPath } from "../../paths.js"; +import { classNames } from "../../ui/classes.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { Icon, type IconName } from "../../ui/icons.js"; +import { Panel } from "../../ui/panels.js"; +import type { ViewChild } from "../../ui/types.js"; +import { profileSkinPart } from "../../skins/rendering.js"; + +type ProfileActionProps = { + user: CurrentUser | null; + csrf: string; + profile: UserProfile; + isFriend: boolean; + pendingSent: boolean; + pendingReceived: boolean; + ownProfile: boolean; + blockedByMe: boolean; + protectedAdminProfile: boolean; +}; + +type FriendAction = { + action: "add" | "accept" | "remove"; + icon: IconName; + label: string; + disabled?: boolean; + variant?: ProfileActionVariant; +}; + +type ProfileActionVariant = "danger" | "secondary"; + +export function ProfileActionsPanel(props: ProfileActionProps) { + const friendAction: FriendAction = props.isFriend + ? { action: "remove", icon: "delete", label: "Remove friend", variant: "danger" } + : props.pendingReceived + ? { action: "accept", icon: "check", label: "Accept friend request" } + : { action: "add", icon: "add", label: props.pendingSent ? "Pending request" : "Add to friends", disabled: props.pendingSent }; + const canModerateProfile = Boolean(props.user && !props.ownProfile && canModerateTarget(props.user, props.profile)); + + return ( + +
    + + {props.ownProfile ? Edit profile : props.user && props.protectedAdminProfile ? ( + Friend + ) : props.user ? ( +
    + + + + + {friendAction.label} + + + ) : Add to friends} +
    + + {props.user && !props.ownProfile ? ( +
    + + + Add to favorites + + ) : Add to favorites} +
    + + + {props.ownProfile ? Instant message : ( + Instant message + )} + + + {props.ownProfile ? Report user : ( + Report user + )} + + {props.user && !props.ownProfile && !props.protectedAdminProfile ? ( + +
    + + + + + {props.blockedByMe ? "Unblock user" : "Block user"} + + +
    + ) : null} + {canModerateProfile ? : null} +
    +
    + ); +} + +function ActionCell(props: { children: ViewChild }) { + return
    {props.children}
    ; +} + +function ProfileActionButton(props: { + children: ViewChild; + disabled?: boolean; + icon: IconName; + variant?: ProfileActionVariant; +}) { + return ( + + ); +} + +function ProfileActionLink(props: { children: ViewChild; href: string; icon: IconName; variant?: ProfileActionVariant }) { + return ( + + {props.children} + + ); +} + +function ProfileActionLabel(props: { children: ViewChild; disabled?: boolean; icon: IconName; variant?: ProfileActionVariant }) { + return ( + + {props.children} + + ); +} + +function ProfileActionContent(props: { children: ViewChild; icon: IconName }) { + return ( + <> + + {props.children} + + ); +} + +function profileActionClass(variant?: ProfileActionVariant, disabled?: boolean) { + return classNames("profile-action", variant && `profile-action--${variant}`, disabled && "profile-action--disabled"); +} + +function MessageActions(props: { profile: UserProfile; ownProfile: boolean }) { + return ( + <> + + {props.ownProfile ? Send message : ( + Send message + )} + + + Forward to friend + + + ); +} + +function StaffActions(props: ProfileActionProps) { + return ( + <> + + + {isAdminUser(props.user) ? "Admin details" : "Open reports"} + + + +
    + + + + {props.profile.bannedAt ? "Unban user" : "Ban user"} + + +
    + + ); +} diff --git a/src/views/profile/details.tsx b/src/views/profile/details.tsx new file mode 100644 index 0000000..8ad8280 --- /dev/null +++ b/src/views/profile/details.tsx @@ -0,0 +1,49 @@ +import { defaultInterestNames, type UserProfile } from "../../models.js"; +import { hasSocialLinks, socialLinkPlatforms } from "../../socialLinks.js"; +import { Panel } from "../../ui/panels.js"; +import { SocialLinkIcon } from "../../ui/socialLinks.js"; +import { profileSkinPart } from "../../skins/rendering.js"; + +export function ProfileSocialLinks({ profile }: { profile: UserProfile }) { + if (!hasSocialLinks(profile.socialLinks)) return null; + + return ( + + + + ); +} + +export function ProfileInterests({ profile }: { profile: UserProfile }) { + const interests = defaultInterestNames + .map((name) => ({ name, value: profile.interests[name] })) + .filter((interest) => interest.value.trim()); + if (!interests.length) return null; + + return ( + + + + {interests.map(({ name, value }) => ( + + + + + ))} + +

    {name}

    {value}

    +
    + ); +} diff --git a/src/views/profile/edit/form.tsx b/src/views/profile/edit/form.tsx new file mode 100644 index 0000000..bb65856 --- /dev/null +++ b/src/views/profile/edit/form.tsx @@ -0,0 +1,110 @@ +import { defaultInterestNames, type UserProfile } from "../../../models.js"; +import { limits, mediaAccept, uploadSizeLabel } from "../../../policy.js"; +import { userTextFromHtml } from "../../../server/security/html.js"; +import { socialLinkPlatforms } from "../../../socialLinks.js"; +import { ActionLabel } from "../../../ui/actions.js"; +import { CharacterLimitHint, FormActions, FormField } from "../../../ui/forms.js"; +import { SocialLinkLabel } from "../../../ui/socialLinks.js"; +import { ProfileSkinHtmlHint, ProfileSkinsPageLink } from "../../../skins/docs.js"; +import { SkinColorPaletteEditor } from "../../../skins/colorPaletteEditor.js"; +import type { ColorPalette } from "../../../theme/colorPalette.js"; +import { ProfileEditSection, sectionError } from "./section.js"; +import type { ProfileEditError } from "./types.js"; + +type ProfileFormProps = { + csrf: string; + error?: ProfileEditError; + profile: UserProfile; + skinColorPaletteFallback: ColorPalette; +}; + +export function ProfileForm({ csrf, error, profile, skinColorPaletteFallback }: ProfileFormProps) { + return ( + <> +

    Change name:

    + + + + + }> + + + +

    Theme song:

    + + + + + + + + +

    Bio:

    + + + + + }> + + + +

    Interests:

    + + {defaultInterestNames.map((name) => ( + + + + ))} + + + + +

    Social links:

    + + {socialLinkPlatforms.map((platform) => ( + } hint={}> + + + ))} + }> + + + +

    Skin:

    + + + } hint={}> + + + }> + + + + + ); +} + +function SocialLinkHint({ platform }: { platform: (typeof socialLinkPlatforms)[number] }) { + return ( + <> + Use a {platform.label.toLowerCase()} URL like{" "} + {platform.exampleLabel} + + ); +} + +function SkinHtmlLabel() { + return ( + <> + Skin HTML + + ); +} diff --git a/src/views/profile/edit/index.ts b/src/views/profile/edit/index.ts new file mode 100644 index 0000000..6cf8fa0 --- /dev/null +++ b/src/views/profile/edit/index.ts @@ -0,0 +1,4 @@ +export { ProfileEditPage } from "./page.js"; +export { ProfileStatusPage } from "./statusPage.js"; +export { isProfileEditSectionName } from "./types.js"; +export type { ProfileEditError, ProfileEditSectionName } from "./types.js"; diff --git a/src/views/profile/edit/page.tsx b/src/views/profile/edit/page.tsx new file mode 100644 index 0000000..3228cc2 --- /dev/null +++ b/src/views/profile/edit/page.tsx @@ -0,0 +1,38 @@ +import type { UserProfile } from "../../../models.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { FormError } from "../../../ui/forms.js"; +import { BackLink } from "../../../ui/links.js"; +import { ProfileImage } from "../../../ui/avatars.js"; +import { profilePath } from "../../../paths.js"; +import { skinColorPaletteEditorStylesheet } from "../../../skins/colorPaletteEditor.js"; +import type { ColorPalette } from "../../../theme/colorPalette.js"; +import { Layout, PageFrame } from "../../../shell/index.js"; +import { ProfileForm } from "./form.js"; +import { sectionError } from "./section.js"; +import { ProfilePhotoForm } from "./photoForm.js"; +import type { ProfileEditError } from "./types.js"; + +export function ProfileEditPage(props: { + user: CurrentUser; + csrf: string; + profile: UserProfile; + skinColorPaletteFallback: ColorPalette; + error?: ProfileEditError; + message?: string; +}) { + const error = props.error ?? (props.message ? { message: props.message } : undefined); + return ( + + } title="Edit profile"> + {!error?.section ? {error?.message} : null} +
    + +
    + +
    +
    + +
    +
    + ); +} diff --git a/src/views/profile/edit/photoForm.tsx b/src/views/profile/edit/photoForm.tsx new file mode 100644 index 0000000..94c3955 --- /dev/null +++ b/src/views/profile/edit/photoForm.tsx @@ -0,0 +1,17 @@ +import { mediaAccept, uploadSizeLabel } from "../../../policy.js"; +import { ActionLabel } from "../../../ui/actions.js"; +import { FormActions, FormField } from "../../../ui/forms.js"; +import { ProfileEditSection } from "./section.js"; + +export function ProfilePhotoForm({ csrf, error }: { csrf: string; error?: string }) { + return ( + + + + + + + + + ); +} diff --git a/src/views/profile/edit/section.tsx b/src/views/profile/edit/section.tsx new file mode 100644 index 0000000..30a7562 --- /dev/null +++ b/src/views/profile/edit/section.tsx @@ -0,0 +1,24 @@ +import { CsrfInput, FormError, FormStack } from "../../../ui/forms.js"; +import type { ViewChild } from "../../../ui/types.js"; +import type { ProfileEditError, ProfileEditSectionName } from "./types.js"; + +export function ProfileEditSection(props: { + csrf: string; + section: ProfileEditSectionName; + children: ViewChild; + error?: string; + multipart?: boolean; +}) { + return ( + + + + {props.error} + {props.children} + + ); +} + +export function sectionError(error: ProfileEditError | undefined, section: ProfileEditSectionName) { + return error?.section === section ? error.message : undefined; +} diff --git a/src/views/profile/edit/statusPage.tsx b/src/views/profile/edit/statusPage.tsx new file mode 100644 index 0000000..5a7c739 --- /dev/null +++ b/src/views/profile/edit/statusPage.tsx @@ -0,0 +1,27 @@ +import type { UserProfile } from "../../../models.js"; +import { limits } from "../../../policy.js"; +import type { CurrentUser } from "../../../currentUser.js"; +import { ActionLabel } from "../../../ui/actions.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../../ui/forms.js"; +import { Layout, PageFrame } from "../../../shell/index.js"; + +export function ProfileStatusPage(props: { user: CurrentUser; csrf: string; profile: UserProfile }) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/src/views/profile/edit/types.ts b/src/views/profile/edit/types.ts new file mode 100644 index 0000000..52df3cd --- /dev/null +++ b/src/views/profile/edit/types.ts @@ -0,0 +1,13 @@ +const profileEditSectionNames = ["name", "profilePhoto", "themeSong", "bio", "interests", "socialLinks", "skin"] as const; +export type ProfileEditSectionName = (typeof profileEditSectionNames)[number]; + +const profileEditSectionSet = new Set(profileEditSectionNames); + +export function isProfileEditSectionName(section: string): section is ProfileEditSectionName { + return profileEditSectionSet.has(section); +} + +export type ProfileEditError = { + message: string; + section?: ProfileEditSectionName; +}; diff --git a/src/views/profile/index.ts b/src/views/profile/index.ts new file mode 100644 index 0000000..498500d --- /dev/null +++ b/src/views/profile/index.ts @@ -0,0 +1,3 @@ +export { ProfilePage } from "./page.js"; +export { ProfileLayout } from "./layout.js"; +export type { ProfilePageProps } from "./pageProps.js"; diff --git a/src/views/profile/layout.tsx b/src/views/profile/layout.tsx new file mode 100644 index 0000000..117cd0f --- /dev/null +++ b/src/views/profile/layout.tsx @@ -0,0 +1,37 @@ +import { friendshipStatus } from "../../policy.js"; +import { SplitLayout } from "../../shell/index.js"; +import { ProfileMain } from "./main.js"; +import { ProfileSidebar } from "./sidebar.js"; +import { profileSkinFromHtml, profileSkinRoot, type ProfileSkin } from "../../skins/rendering.js"; +import type { ProfilePageProps } from "./pageProps.js"; + +export function ProfileLayout(props: ProfilePageProps & { skin?: ProfileSkin }) { + const ownProfile = props.user?.id === props.profile.id; + const isFriend = props.friendship?.status === friendshipStatus.accepted; + const pendingFriendship = props.friendship?.status === friendshipStatus.pending ? props.friendship : undefined; + const skin = props.skin ?? profileSkinFromHtml(props.profile.skinHtml); + + return ( + + + + + ); +} diff --git a/src/views/profile/main.tsx b/src/views/profile/main.tsx new file mode 100644 index 0000000..76aad70 --- /dev/null +++ b/src/views/profile/main.tsx @@ -0,0 +1,62 @@ +import { UserContent } from "../../ui/userContent.js"; +import { PeopleBox } from "../../ui/people.js"; +import { PreviewTitleLink } from "../../ui/previews.js"; +import { Panel } from "../../ui/panels.js"; +import { SplitPane } from "../../shell/index.js"; +import { WallBox } from "../posts/index.js"; +import { ProfileCustomSkin, profileSkinPart, type ProfileSkin } from "../../skins/rendering.js"; +import { blogPath } from "../../paths.js"; +import type { ProfilePageProps } from "./pageProps.js"; + +export function ProfileMain(props: ProfilePageProps & { isFriend: boolean; ownProfile: boolean; skin: ProfileSkin }) { + const hasBio = Boolean(props.profile.bioHtml.trim()); + const customSkin = props.skin.bodyHtml ? : null; + + return ( + + {props.isFriend ?

    {props.profile.username} is your friend.

    : null} + {props.ownProfile ? : null} + {hasBio ? ( + + + + ) : null} + {customSkin} + + + {props.profile.username} has {props.friendCount} friends.

    } + /> +
    + ); +} + +function ProfileBlogPreview(props: ProfilePageProps) { + return ( + [View blog] : null} + title={`${props.profile.username}'s latest blog entries`} + > + {props.blogs.length ? props.blogs.map((entry) => ( +

    + )) :

    There are no blog entries yet.

    } +
    + ); +} diff --git a/src/views/profile/page.tsx b/src/views/profile/page.tsx new file mode 100644 index 0000000..ad8fadf --- /dev/null +++ b/src/views/profile/page.tsx @@ -0,0 +1,60 @@ +import { Layout } from "../../shell/index.js"; +import { profileImagePath, profilePath } from "../../paths.js"; +import { defaultProfileImageNames } from "../../policy.js"; +import { absoluteUrl } from "../../server/indexing/urls.js"; +import { plainTextFromHtml } from "../../server/security/html.js"; +import { seoText, type PageSeo } from "../../settings/seo.js"; +import { skinBrowserThemeColorFromHtml } from "../../skins/colorPalette.js"; +import { profileSkinFromHtml, profileSkinPageAttributes, ProfileSkinStyles } from "../../skins/rendering.js"; +import { ProfileLayout } from "./layout.js"; +import type { ProfilePageProps } from "./pageProps.js"; + +export function ProfilePage(props: ProfilePageProps) { + const skin = profileSkinFromHtml(props.profile.skinHtml); + return ( + skinBrowserThemeColorFromHtml(skin.styleHtml, branding.palette)} + head={} + seo={profileSeo(props)} + > + + + ); +} + +function profileSeo(props: ProfilePageProps): PageSeo { + if (props.profile.private) return { noindex: true }; + + const description = profileDescription(props); + const url = absoluteUrl(profilePath(props.profile)); + const image = defaultProfileImageNames.has(props.profile.pfp) ? null : absoluteUrl(profileImagePath(props.profile.pfp)); + const sameAs = Object.values(props.profile.socialLinks).filter(Boolean); + + return { + canonicalPath: profilePath(props.profile), + description, + jsonLd: { + "@context": "https://schema.org", + "@type": "Person", + name: props.profile.username, + alternateName: props.profile.handle, + url, + ...(description ? { description } : {}), + ...(image ? { image } : {}), + ...(sameAs.length ? { sameAs } : {}) + }, + title: `${props.profile.username}'s profile`, + type: "profile" + }; +} + +function profileDescription(props: ProfilePageProps) { + return ( + seoText(plainTextFromHtml(props.profile.bioHtml), 180) || + seoText(props.profile.status.status || props.profile.status.currentVibe, 180) || + `${props.profile.username}'s public profile.` + ); +} diff --git a/src/views/profile/pageProps.ts b/src/views/profile/pageProps.ts new file mode 100644 index 0000000..2f9c6d1 --- /dev/null +++ b/src/views/profile/pageProps.ts @@ -0,0 +1,23 @@ +import type { BlogListItem, Friendship, PersonCard, PostItem, UserProfile } from "../../models.js"; +import type { CurrentUser } from "../../currentUser.js"; + +export type ProfilePageProps = { + user: CurrentUser | null; + csrf: string; + profile: UserProfile; + profileUrlLabel: string; + friendCount: number; + frontRow: PersonCard[]; + blogs: BlogListItem[]; + blogHref?: string | null; + friendsHref?: string | null; + wallPosts: PostItem[]; + fullWall?: boolean; + wallNextHref?: string | null; + wallResetHref?: string | null; + wallViewAllHref?: string | null; + canPost: boolean; + friendship?: Friendship; + blockedByMe?: boolean; + protectedAdminProfile?: boolean; +}; diff --git a/src/views/profile/sidebar.tsx b/src/views/profile/sidebar.tsx new file mode 100644 index 0000000..6e4f1e4 --- /dev/null +++ b/src/views/profile/sidebar.tsx @@ -0,0 +1,74 @@ +import type { UserProfile } from "../../models.js"; +import { defaultMedia } from "../../policy.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { SplitPane } from "../../shell/index.js"; +import { ProfileImage } from "../../ui/avatars.js"; +import { profilePath, themeSongPath } from "../../paths.js"; +import { ProfileActionsPanel } from "./actions.js"; +import { ProfileInterests, ProfileSocialLinks } from "./details.js"; +import { profileSkinPart } from "../../skins/rendering.js"; + +type ProfileSidebarProps = { + user: CurrentUser | null; + csrf: string; + profile: UserProfile; + profileUrlLabel: string; + isFriend: boolean; + pendingSent: boolean; + pendingReceived: boolean; + ownProfile: boolean; + blockedByMe: boolean; + protectedAdminProfile: boolean; +}; + +export function ProfileSidebar(props: ProfileSidebarProps) { + const hasThemeSong = props.profile.themeSong !== defaultMedia.themeSong; + const themeSongType = props.profile.themeSong.endsWith(".ogg") ? "audio/ogg" : "audio/mpeg"; + const profileUrlPath = profilePath(props.profile); + const status = props.profile.status.status.trim(); + const currentVibe = props.profile.status.currentVibe.trim(); + + return ( + +
    +

    {props.profile.username}

    +
    +
    + +
    + {status ? ( +
    +

    "{status}"

    +
    + ) : null} +
    +
    + {hasThemeSong ? ( + + ) : null} + {currentVibe ? ( +
    +

    Current vibe: {currentVibe}

    +
    + ) : null} + +
    +

    Profile URL: {props.profileUrlLabel}

    +
    + + +
    + ); +} diff --git a/src/views/reports/index.tsx b/src/views/reports/index.tsx new file mode 100644 index 0000000..37da8cf --- /dev/null +++ b/src/views/reports/index.tsx @@ -0,0 +1,40 @@ +import { limits, type ReportSubjectType } from "../../policy.js"; +import { reportPathBase } from "../../paths.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack, FormSuccess } from "../../ui/forms.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +type ReportPageProps = { + user: CurrentUser; + csrf: string; + subjectType?: ReportSubjectType; + subjectId?: number; + error?: string; + message?: string; +}; + +export function ReportPage(props: ReportPageProps) { + const canSubmit = !props.message && props.subjectType && props.subjectId; + return ( + + + {props.error} + {props.message} + {canSubmit ? ( + + + + + + + + + + + + ) : null} + + + ); +} diff --git a/src/views/search/index.tsx b/src/views/search/index.tsx new file mode 100644 index 0000000..5b39f05 --- /dev/null +++ b/src/views/search/index.tsx @@ -0,0 +1,75 @@ +import type { CurrentUser } from "../../currentUser.js"; +import { limits } from "../../policy.js"; +import type { SiteSearchResults } from "../../server/db/search.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { GroupSummaryCard } from "../../ui/groups.js"; +import { PeopleBox } from "../../ui/people.js"; +import type { ViewChild } from "../../ui/types.js"; +import { BlogCardList } from "../blogs/index.js"; +import { PostList } from "../posts/index.js"; +import { SkinSummaryCard } from "../skins/index.js"; +import { AuthorSkinStyles } from "../../skins/rendering.js"; + +export function SearchPage(props: { user: CurrentUser | null; csrf: string; query: string; results: SiteSearchResults | null }) { + const hasResults = props.results ? hasSearchResults(props.results) : false; + return ( + : null} + > + +
    + + +
    + {props.results ? ( + hasResults ? :

    Nothing found.

    + ) : null} +
    +
    + ); +} + +function hasSearchResults(results: SiteSearchResults) { + return results.people.length > 0 || results.blogs.length > 0 || results.groups.length > 0 || results.posts.length > 0 || results.skins.length > 0; +} + +function SearchResults(props: { user: CurrentUser | null; csrf: string; results: SiteSearchResults }) { + const results = props.results; + return ( + <> + {results.people.length ? : null} + {results.blogs.length ? ( + + + + ) : null} + {results.groups.length ? ( + + {results.groups.map((group) => )} + + ) : null} + {results.posts.length ? ( + + + + ) : null} + {results.skins.length ? ( + + {results.skins.map((skin) => )} + + ) : null} + + ); +} + +function SearchSection(props: { title: string; children: ViewChild }) { + return ( +
    +

    {props.title}

    + {props.children} +
    + ); +} diff --git a/src/views/skins/index.tsx b/src/views/skins/index.tsx new file mode 100644 index 0000000..1a49f70 --- /dev/null +++ b/src/views/skins/index.tsx @@ -0,0 +1,220 @@ +import type { CommentItem, SkinItem } from "../../models.js"; +import { limits } from "../../policy.js"; +import { canModerateTarget, isAdminUser } from "../../roles.js"; +import { plainTextFromHtml, userTextFromHtml } from "../../server/security/html.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { truncateText } from "../../text.js"; +import { ActionBar, ActionLabel } from "../../ui/actions.js"; +import { CommentPanel } from "../../ui/comments.js"; +import { UserContent } from "../../ui/userContent.js"; +import { CsrfInput, FormActions, FormError, FormField, FormStack } from "../../ui/forms.js"; +import { BackLink, BackToPage } from "../../ui/links.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { profilePath, reportPath, skinCommentsPath, skinPath } from "../../paths.js"; +import { absoluteUrl } from "../../server/indexing/urls.js"; +import { seoText, type PageSeo } from "../../settings/seo.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { ProfileLayout, type ProfilePageProps } from "../profile/index.js"; +import { builtinSkinAttribution } from "../../skins/builtin.js"; +import { skinBrowserThemeColorFromHtml } from "../../skins/colorPalette.js"; +import { ProfileSkinDocsLink, ProfileSkinHtmlHint } from "../../skins/docs.js"; +import { AuthorSkinStyles, profileSkinFromHtml, profileSkinPageAttributes, ProfileSkinStyles } from "../../skins/rendering.js"; +import { SkinColorPaletteEditor, skinColorPaletteEditorStylesheet } from "../../skins/colorPaletteEditor.js"; +import type { ColorPalette } from "../../theme/colorPalette.js"; + +export type SkinFormDraft = { + code?: string; + description?: string; + title?: string; +}; + +type SkinFormPageProps = { + user: CurrentUser; + csrf: string; + skinColorPaletteFallback: ColorPalette; + skin?: SkinItem; + draft?: SkinFormDraft; + message?: string; +}; + +export function SkinListPage(props: { user: CurrentUser | null; skins: SkinItem[] }) { + return ( + + + {props.user ? ( + Submit skin} + secondary={} + /> + ) :

    } + {props.skins.length ? props.skins.map((skin) => ) :

    No skins have been submitted yet.

    } +
    +
    + ); +} + +export function SkinSummaryCard({ skin, preview = false }: { skin: SkinItem; preview?: boolean }) { + return ( +
    +

    {skin.title}

    +

    By

    + {preview ?

    {truncateText(plainTextFromHtml(skin.descriptionHtml), 180)}

    : } +
    + ); +} + +export function SkinFormPage(props: SkinFormPageProps) { + const skin = props.skin; + const title = props.draft?.title ?? skin?.title ?? ""; + const description = props.draft?.description ?? (skin ? userTextFromHtml(skin.descriptionHtml) : ""); + const code = props.draft?.code ?? skin?.codeHtml ?? ""; + return ( + + : } + title={skin ? "Edit skin" : "Submit skin"} + > + {props.message} + + + + + + + + + + }> + + + + + + + + + ); +} + +export function SkinDetailPage(props: { + user: CurrentUser | null; + csrf: string; + skin: SkinItem; + comments: CommentItem[]; + commentsHref?: string | null; + fullComments?: boolean; +}) { + const canEdit = props.skin.authorId === null ? isAdminUser(props.user) : props.user?.id === props.skin.authorId || isAdminUser(props.user); + const canDelete = + canEdit || Boolean(props.user && props.skin.authorId !== null && canModerateTarget(props.user, { id: props.skin.authorId, role: props.skin.authorRole })); + const href = skinPath(props.skin); + const primaryActions = ( + <> + {props.user ? Preview on your profile : Log in to preview} + {props.user ? ( +
    + + + + ) : null} + + ); + const managementActions = ( + <> + Report + {canDelete ? ( + <> + {canEdit ? Edit : null} +
    + + + + + ) : null} + + ); + return ( + } seo={skinSeo(props.skin)}> + : } + title={props.skin.title} + > +

    By

    + + +

    Code

    + + +
    +
    + ); +} + +function skinSeo(skin: SkinItem): PageSeo { + const path = skinPath(skin); + const description = seoText(plainTextFromHtml(skin.descriptionHtml), 180); + const author = skinAuthorAttribution(skin); + return { + canonicalPath: path, + description, + jsonLd: { + "@context": "https://schema.org", + "@type": "CreativeWork", + name: skin.title, + description, + url: absoluteUrl(path), + datePublished: skin.createdAt, + dateModified: skin.updatedAt, + author: { + "@type": "Person", + name: author.name, + url: absoluteUrl(author.href) + } + }, + modifiedTime: skin.updatedAt, + publishedTime: skin.createdAt, + title: skin.title, + type: "article" + }; +} + +function SkinAuthorLink({ skin }: { skin: SkinItem }) { + const author = skinAuthorAttribution(skin); + return {author.name}; +} + +function skinAuthorAttribution(skin: SkinItem) { + const builtin = builtinSkinAttribution(skin.sourceKey); + return builtin ? { name: builtin.name, href: builtin.url } : { name: skin.username, href: profilePath(skin.authorHandle) }; +} + +function skinOwnerIds(skin: SkinItem) { + return skin.authorId === null ? [] : [skin.authorId]; +} + +export function SkinPreviewPage(props: { user: CurrentUser; skin: SkinItem; profileProps: ProfilePageProps }) { + const skin = profileSkinFromHtml(props.skin.codeHtml); + return ( + skinBrowserThemeColorFromHtml(skin.styleHtml, branding.palette)} + head={} + > + } title={props.skin.title} /> + + + ); +} diff --git a/src/views/staff/audit.tsx b/src/views/staff/audit.tsx new file mode 100644 index 0000000..784b8aa --- /dev/null +++ b/src/views/staff/audit.tsx @@ -0,0 +1,87 @@ +import type { AuditItem } from "../../models.js"; +import { auditActionLabel } from "../../auditLabels.js"; +import { profilePath } from "../../paths.js"; +import { MetaSubject, MetaSubjectLink } from "../../ui/meta.js"; +import { Panel } from "../../ui/panels.js"; +import { LocalizedTime } from "../../ui/time.js"; +import { UserContent } from "../../ui/userContent.js"; + +export function AuditPanel({ audit }: { audit: AuditItem[] }) { + return ( + + {audit.length ? ( +
    + + + + + + + + + + {audit.map((item) => ( + + + + + + + ))} + +
    WhenModeratorActionSubject
    + {auditActionLabel(item.action)} + {item.action} + + +
    +
    + ) :

    No audit records.

    } +
    + ); +} + +function AuditActor({ item }: { item: AuditItem }) { + if (item.actorId && item.actorName && item.actorHandle) { + return ( + <> + {item.actorName} + user #{item.actorId} + + ); + } + if (item.actorId) return <>deleted useruser #{item.actorId}; + return system; +} + +function AuditSubject({ item }: { item: AuditItem }) { + const subject = item.subjectUrl && !item.subjectMissing + ? {item.subjectLabel} + : {item.subjectLabel}; + + return ( + <> + + {subject} + {item.subjectMissing ? removed : null} + + {subjectIdLabel(item)} + {item.subjectAuthorHandle ? ( + + Author: {item.subjectAuthorName ?? `user #${item.subjectAuthorId}`} + + ) : null} + {item.subjectSummary ? {item.subjectSummary} : null} + {item.reasonHtml ? ( +
    + Moderator note + +
    + ) : null} + + ); +} + +function subjectIdLabel(item: AuditItem) { + return item.subjectId > 0 ? `${item.subjectType} #${item.subjectId}` : item.subjectType; +} diff --git a/src/views/staff/automod.tsx b/src/views/staff/automod.tsx new file mode 100644 index 0000000..5bbfeac --- /dev/null +++ b/src/views/staff/automod.tsx @@ -0,0 +1,91 @@ +import type { AutomodRule, AutomodScope } from "../../models.js"; +import { automodActions, automodPatternMax, automodPatternTypes, automodScopes } from "../../automodPolicy.js"; +import { limits } from "../../policy.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../ui/forms.js"; +import { Panel } from "../../ui/panels.js"; +import { LocalizedTime } from "../../ui/time.js"; + +export function AutomodPanel(props: { csrf: string; openRuleId?: number; rules: AutomodRule[] }) { + return ( + +
    +

    Add rule

    + +
    +
    + {props.rules.length + ? props.rules.map((rule) => ) + :

    No automod rules yet.

    } +
    +
    + ); +} + +function AutomodRuleDetails(props: { csrf: string; open?: boolean; rule: AutomodRule }) { + const rule = props.rule; + return ( +
    + + {rule.name} + {rule.patternType} | {rule.scope} | {automodActionLabel(rule.action)} | {rule.enabled ? "enabled" : "disabled"} + + + + + + + + +

    Updated {rule.createdByName ? <> by {rule.createdByName} : null}

    +
    + ); +} + +function AutomodRuleForm(props: { csrf: string; rule?: AutomodRule }) { + const rule = props.rule; + return ( + + + + {rule ? : null} + + + + + + + + + + + + + + + + + Enabled + + + + + + ); +} + +function AutomodScopeSelect(props: { selected: AutomodScope }) { + return ( + + ); +} + +function automodActionLabel(action: AutomodRule["action"]) { + return action === "reject" ? "Reject submission" : "Report for review"; +} diff --git a/src/views/staff/blog.tsx b/src/views/staff/blog.tsx new file mode 100644 index 0000000..cf896ac --- /dev/null +++ b/src/views/staff/blog.tsx @@ -0,0 +1,22 @@ +import type { BlogItem } from "../../models.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { Panel } from "../../ui/panels.js"; +import { blogPath, profilePath } from "../../paths.js"; + +export function BlogToolsPanel(props: { csrf: string; blogs: BlogItem[] }) { + return ( + + {props.blogs.length ? props.blogs.map((blog) => ( +
    + {blog.title} by {blog.username}{" "} +
    + + + +
    + )) :

    No blog entries.

    } +
    + ); +} diff --git a/src/views/staff/database.tsx b/src/views/staff/database.tsx new file mode 100644 index 0000000..25780a1 --- /dev/null +++ b/src/views/staff/database.tsx @@ -0,0 +1,10 @@ +import type { TableCount } from "../../models.js"; +import { Panel } from "../../ui/panels.js"; + +export function DatabasePanel({ counts }: { counts: TableCount[] }) { + return ( + + {counts.map((count) => )}
    {count.name}{count.count}
    +
    + ); +} diff --git a/src/views/staff/email.tsx b/src/views/staff/email.tsx new file mode 100644 index 0000000..2542380 --- /dev/null +++ b/src/views/staff/email.tsx @@ -0,0 +1,40 @@ +import type { EmailOutboxItem } from "../../models.js"; +import { limits } from "../../policy.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../ui/forms.js"; +import { Panel } from "../../ui/panels.js"; +import { LocalizedTime } from "../../ui/time.js"; + +export function EmailPanel(props: { csrf: string; outbox: EmailOutboxItem[] }) { + return ( + + + + + + + + + + + + + + + + + {props.outbox.length ? props.outbox.map((email) => ) :

    No queued email.

    } +
    + ); +} + +function EmailDetails({ email }: { email: EmailOutboxItem }) { + return ( +
    + + {email.toEmail} | {email.subject} | {email.sentAt ? <>sent : email.deliveryError ? "delivery failed" : "queued"} | + + {email.deliveryError ?

    {email.deliveryError}

    : null} +
    {email.bodyText}
    +
    + ); +} diff --git a/src/views/staff/favorites.tsx b/src/views/staff/favorites.tsx new file mode 100644 index 0000000..dc491e1 --- /dev/null +++ b/src/views/staff/favorites.tsx @@ -0,0 +1,23 @@ +import type { FavoriteEdge } from "../../models.js"; +import { CsrfInput } from "../../ui/forms.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { Panel } from "../../ui/panels.js"; +import { profilePath } from "../../paths.js"; + +export function FavoritesPanel(props: { csrf: string; favorites: FavoriteEdge[] }) { + return ( + + {props.favorites.length ? props.favorites.map((favorite) => ( +
    + {favorite.username} saved {favorite.favoriteName}{" "} +
    + + + + + +
    + )) :

    No favorites.

    } +
    + ); +} diff --git a/src/views/staff/index.tsx b/src/views/staff/index.tsx new file mode 100644 index 0000000..383bb74 --- /dev/null +++ b/src/views/staff/index.tsx @@ -0,0 +1,72 @@ +import type { AuditItem, AutomodRule, BlogItem, EmailOutboxItem, FavoriteEdge, RateLimitSetting, ReportItem, StaffUserRow, TableCount } from "../../models.js"; +import type { BrandingSettings } from "../../settings/branding.js"; +import type { SiteSettings } from "../../settings/site.js"; +import { isAdminUser } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { colorSwatchesStylesheet } from "../../ui/colorSwatches.js"; +import { Layout, PageFrame } from "../../shell/index.js"; +import { AuditPanel } from "./audit.js"; +import { AutomodPanel } from "./automod.js"; +import { BlogToolsPanel } from "./blog.js"; +import { DatabasePanel } from "./database.js"; +import { EmailPanel } from "./email.js"; +import { FavoritesPanel } from "./favorites.js"; +import { adminLinks, moderationLinks, StaffSubnav } from "./nav.js"; +import { RateLimitsPanel } from "./rateLimits.js"; +import { ReportsPanel } from "./reports.js"; +import { SiteSettingsPanel } from "./siteSettings.js"; +import { UserSearch, UsersPanel } from "./users.js"; + +export { StaffUserDetailPage } from "./userDetail.js"; + +export type StaffSection = "overview" | "users" | "reports" | "automod" | "rateLimits" | "siteSettings" | "database" | "blog" | "favorites" | "email" | "audit"; +type StaffArea = "admin" | "moderation"; + +type StaffPageProps = { + user: CurrentUser; + csrf: string; + section: StaffSection; + users: StaffUserRow[]; + reports: ReportItem[]; + resolvedReports: ReportItem[]; + automodRules: AutomodRule[]; + counts: TableCount[]; + blogs: BlogItem[]; + favorites: FavoriteEdge[]; + outbox: EmailOutboxItem[]; + rateLimitSettings: RateLimitSetting[]; + rateLimitRaidActive: boolean; + colorTheme: BrandingSettings | null; + siteSettings: SiteSettings | null; + audit: AuditItem[]; + area?: StaffArea; + openAutomodRuleId?: number; + query?: string; +}; + +export function StaffPage(props: StaffPageProps) { + const isAdmin = isAdminUser(props.user); + const area = props.area ?? (isAdmin ? "admin" : "moderation"); + const title = area === "admin" && isAdmin ? "Admin" : "Moderation"; + const show = (section: StaffSection) => props.section === "overview" || props.section === section; + const reportAction = area === "admin" ? "/admin/reports" : "/moderation/reports"; + + return ( + + + + {show("users") ? : null} + {show("reports") ? : null} + {show("automod") && isAdmin ? : null} + {show("rateLimits") && isAdmin ? : null} + {show("siteSettings") && isAdmin && props.colorTheme && props.siteSettings ? : null} + {show("users") ? : null} + {show("database") ? : null} + {show("blog") ? : null} + {show("favorites") ? : null} + {show("email") ? : null} + {show("audit") ? : null} + + + ); +} diff --git a/src/views/staff/nav.tsx b/src/views/staff/nav.tsx new file mode 100644 index 0000000..9736081 --- /dev/null +++ b/src/views/staff/nav.tsx @@ -0,0 +1,36 @@ +import { Icon, type IconName } from "../../ui/icons.js"; + +type StaffSectionLink = { + readonly label: string; + readonly href: string; + readonly icon: IconName; +}; + +export const adminLinks: readonly StaffSectionLink[] = [ + { label: "Users", href: "/admin/users", icon: "user" }, + { label: "Reports", href: "/admin/reports", icon: "report" }, + { label: "Automod", href: "/admin/automod", icon: "audit" }, + { label: "Rate limits", href: "/admin/rate-limits", icon: "rate-limit" }, + { label: "Site settings", href: "/admin/branding", icon: "settings" }, + { label: "Database", href: "/admin/database", icon: "database" }, + { label: "Blog", href: "/admin/blog", icon: "blog" }, + { label: "Favorites", href: "/admin/favorites", icon: "favorite" }, + { label: "Email outbox", href: "/admin/email", icon: "email" }, + { label: "Audit", href: "/admin/audit", icon: "audit" } +]; + +export const moderationLinks: readonly StaffSectionLink[] = [ + { label: "Reports", href: "/moderation/reports", icon: "report" } +]; + +export function StaffSubnav({ links }: { links: readonly StaffSectionLink[] }) { + return ( +

    + {links.map((link) => ( + + {link.label} + + ))} +

    + ); +} diff --git a/src/views/staff/rateLimits.tsx b/src/views/staff/rateLimits.tsx new file mode 100644 index 0000000..66ce63c --- /dev/null +++ b/src/views/staff/rateLimits.tsx @@ -0,0 +1,80 @@ +import type { RateLimitSetting } from "../../models.js"; +import { rateLimitActionLabel } from "../../rateLimitLabels.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { CsrfInput, FormActions, FormStack } from "../../ui/forms.js"; +import { Icon } from "../../ui/icons.js"; +import { Panel } from "../../ui/panels.js"; + +export function RateLimitsPanel(props: { csrf: string; raidActive: boolean; settings: RateLimitSetting[] }) { + return ( + + + + + + ) : ( +
    + + + + + ) + } + > + {props.raidActive ? ( + + ) : null} + + + +
    + + + + + + + + + {props.settings.map((setting) => ( + + + + + + ))} + +
    What this limitsAllowed actionsWindow (seconds)
    + {rateLimitActionLabel(setting.action)} + {setting.action} + + + + + + + + +
    +
    +
    Set allowed actions to 0 to block that action.
    + + + +
    +
    + + + + +
    + ); +} diff --git a/src/views/staff/reports.tsx b/src/views/staff/reports.tsx new file mode 100644 index 0000000..96c3db9 --- /dev/null +++ b/src/views/staff/reports.tsx @@ -0,0 +1,74 @@ +import type { ReportItem } from "../../models.js"; +import type { ViewChild } from "../../ui/types.js"; +import { limits } from "../../policy.js"; +import { UserContent } from "../../ui/userContent.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../ui/forms.js"; +import { MetaSubject, MetaSubjectLink } from "../../ui/meta.js"; +import { Panel } from "../../ui/panels.js"; +import { profilePath } from "../../paths.js"; +import { LocalizedTime } from "../../ui/time.js"; + +export function ReportsPanel(props: { csrf: string; reports: ReportItem[]; resolvedReports: ReportItem[]; action: string }) { + return ( + <> + + {props.reports.length ? props.reports.map((report) => ) :

    No open reports.

    } +
    + + {props.resolvedReports.length ? props.resolvedReports.map((report) => ) :

    No resolved reports.

    } +
    + + ); +} + +function ReportCard(props: { children?: ViewChild; report: ReportItem }) { + const report = props.report; + return ( +
    +

    + {report.subjectUrl && !report.subjectMissing ? {report.subjectLabel} : report.subjectLabel}{" "} + reported by {report.reporterName ? {report.reporterName} : "deleted user"} on +

    +

    + Author: {report.subjectAuthorHandle ? {report.subjectAuthorName ?? `user #${report.subjectAuthorId}`} : "unknown"} + {report.subjectMissing ? " | subject removed" : null} +

    + {report.subjectSummary ?

    {report.subjectSummary}

    : null} + + {props.children} +
    + ); +} + +function OpenReportCard(props: { action: string; csrf: string; report: ReportItem }) { + const report = props.report; + return ( + + + + + + + + + + {report.subjectCanDelete ? : null} + {report.subjectAuthorId ? : null} + {report.subjectCanDelete && report.subjectAuthorId ? : null} + + + + ); +} + +function ResolvedReportCard(props: { report: ReportItem }) { + return ( + + {props.report.resolvedAt ? ( +

    + Resolved{props.report.resolvedByName ? <> by {props.report.resolvedByName} : null} on +

    + ) : null} +
    + ); +} diff --git a/src/views/staff/siteSettings.tsx b/src/views/staff/siteSettings.tsx new file mode 100644 index 0000000..3a624c3 --- /dev/null +++ b/src/views/staff/siteSettings.tsx @@ -0,0 +1,107 @@ +import type { BrandingSettings } from "../../settings/branding.js"; +import type { SiteSettings } from "../../settings/site.js"; +import { limits } from "../../policy.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { ColorSwatches } from "../../ui/colorSwatches.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../ui/forms.js"; +import { SvgIcon } from "../../ui/icons.js"; +import { Panel } from "../../ui/panels.js"; +import { siteSettingsTargetIds } from "./siteSettingsTargets.js"; + +export function SiteSettingsPanel({ colorTheme, csrf, siteSettings }: { colorTheme: BrandingSettings; csrf: string; siteSettings: SiteSettings }) { + return ( + <> + + + + + + ); +} + +function SiteIdentityPanel({ csrf, settings }: { csrf: string; settings: SiteSettings }) { + return ( + + + + + + + + + + + Browse icons at lucide.dev/icons.} + > + + + + + + + + + + + ); +} + +function HomePagePanel({ csrf, settings }: { csrf: string; settings: SiteSettings }) { + return ( + + + + + + + + + + + + + + + + ); +} + +function ContactPanel({ csrf, settings }: { csrf: string; settings: SiteSettings }) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function ColorThemePanel({ csrf, settings }: { csrf: string; settings: BrandingSettings }) { + return ( + + + + + + + {settings.customized ? : null} + + + + ); +} diff --git a/src/views/staff/siteSettingsTargets.ts b/src/views/staff/siteSettingsTargets.ts new file mode 100644 index 0000000..a214868 --- /dev/null +++ b/src/views/staff/siteSettingsTargets.ts @@ -0,0 +1,15 @@ +export const siteSettingsTargetIds = { + color: "color-theme", + contact: "contact-legal", + home: "home-page", + identity: "site-identity", + resetColor: "color-theme" +} as const; + +export function siteSettingsTargetId(mode: string) { + return isSiteSettingsTargetMode(mode) ? siteSettingsTargetIds[mode] : undefined; +} + +function isSiteSettingsTargetMode(mode: string): mode is keyof typeof siteSettingsTargetIds { + return Object.hasOwn(siteSettingsTargetIds, mode); +} diff --git a/src/views/staff/userDetail.tsx b/src/views/staff/userDetail.tsx new file mode 100644 index 0000000..e680928 --- /dev/null +++ b/src/views/staff/userDetail.tsx @@ -0,0 +1,44 @@ +import type { UserProfile } from "../../models.js"; +import { limits } from "../../policy.js"; +import { userRoles } from "../../roles.js"; +import type { CurrentUser } from "../../currentUser.js"; +import { CsrfInput, FormActions, FormField, FormStack } from "../../ui/forms.js"; +import { BackToPage } from "../../ui/links.js"; +import { Layout, PageFrame } from "../../shell/index.js"; + +export function StaffUserDetailPage(props: { user: CurrentUser; csrf: string; target: UserProfile; protectedAdmin: boolean }) { + return ( + + } title={<>Modify user: {props.target.username}}> +

    Email: {props.target.email}

    +

    Status: {props.target.bannedAt ? "banned" : props.target.verifiedAt ? "verified" : "unverified"}

    + {props.protectedAdmin ? ( +

    This protected admin account cannot be modified.

    + ) : ( + + + + {props.target.verifiedAt ? null : } + + + + + + + + + + + + + + + + + )} +
    +
    + ); +} diff --git a/src/views/staff/users.tsx b/src/views/staff/users.tsx new file mode 100644 index 0000000..c85349d --- /dev/null +++ b/src/views/staff/users.tsx @@ -0,0 +1,38 @@ +import type { StaffUserRow } from "../../models.js"; +import { limits } from "../../policy.js"; +import { ActionLabel } from "../../ui/actions.js"; +import { MetaSubjectLink } from "../../ui/meta.js"; +import { Panel } from "../../ui/panels.js"; +import { profilePath } from "../../paths.js"; + +export function UserSearch({ query }: { query?: string }) { + return ( +
    + + +
    + ); +} + +export function UsersPanel({ users }: { users: StaffUserRow[] }) { + return ( + + + + + {users.map((person) => ( + + + + + + + + + + ))} + +
    IDNameEmailRoleStatusViewsModify
    {person.id}{person.username}{person.email}{person.role}{person.bannedAt ? "banned" : person.verifiedAt ? "verified" : "unverified"}{person.views}Modify
    +
    + ); +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..856f6cf --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d9f8d37 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a51adc1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["node_modules/**", "dist/**"], + fileParallelism: false + } +});

    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 .content-card { + background: var(--color-brand-soft); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-7); + margin: 0; + padding: var(--space-5) var(--space-6); +} + +.content-card > :where(h1, h2, h3, h4, p, ul, ol) { + margin: 0; +} + +.content-card > :where(div) > :first-child { + margin-top: 0; +} + +.content-card > :where(div) > :last-child { + margin-bottom: 0; +} + +.user-content { + color: inherit; + overflow-wrap: break-word; + word-break: break-word; +} + +.user-content > :first-child { + margin-top: 0; +} + +.user-content > :last-child { + margin-bottom: 0; +} + +.user-content :where(p, ul, ol, blockquote) { + margin: 0 0 var(--space-6); +} + +.user-content :where(ul, ol) { + padding-left: var(--space-10); +} + +.user-content li + li { + margin-top: var(--space-2); +} + +.user-content :where(h2, h3) { + color: var(--color-brand); + line-height: var(--line-height-body); + margin: var(--space-8) 0 var(--space-4); +} + +.user-content h2 { + border-bottom: var(--border-thin) solid var(--color-panel-rule); + font-size: 1.15em; + padding-bottom: var(--space-2); +} + +.user-content h3 { + font-size: 1.05em; +} + +.user-content blockquote { + background: var(--surface-background-soft); + border-left: var(--border-medium) solid var(--surface-border); + margin-left: 0; + margin-right: 0; + padding: var(--space-4) var(--space-5); +} + +.user-content code { + background: var(--color-field); + border: var(--border-thin) solid var(--color-field-border); + border-radius: var(--radius-subtle); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.95em; + padding: 0 var(--space-1); +} + +.card-attribution { + font-style: italic; +} + +.card-note { + color: var(--color-text-muted); +} + +.context-card { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--color-brand-accent); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + padding: var(--space-3); +} + +.content-actions { + margin: 0; +} diff --git a/public/static/css/components/discussion.css b/public/static/css/components/discussion.css new file mode 100644 index 0000000..2ca1738 --- /dev/null +++ b/public/static/css/components/discussion.css @@ -0,0 +1,84 @@ +.comments-panel { + margin: 0; +} + +.discussion-list { + display: grid; + gap: var(--space-5); + margin: 0; +} + +.discussion-list > * { + margin-block: 0; +} + +.discussion-entry { + background: var(--surface-background-soft); + border: var(--border-thin) solid var(--surface-border-soft); + border-radius: var(--radius-panel); + box-shadow: var(--shadow-card); + display: grid; + grid-template-columns: var(--discussion-author-width) minmax(0, 1fr); +} + +.discussion-entry--reply { + margin-left: var(--space-8); +} + +.discussion-entry__author { + align-items: center; + background: var(--color-panel-accent); + color: var(--color-brand); + display: flex; + flex-direction: column; + gap: var(--space-3); + font-weight: bold; + justify-content: flex-start; + min-width: 0; + overflow-wrap: anywhere; + padding: var(--space-4); + text-align: center; +} + +.discussion-entry__body { + background: var(--surface-background-soft); + border-left: var(--border-thin) solid var(--surface-background); + min-width: 0; + padding: var(--space-5); +} + +.discussion-entry__meta { + color: var(--color-text-muted); + font-size: var(--font-size-small); + margin-bottom: var(--space-2); +} + +.discussion-entry__actions { + border-top: var(--border-thin) solid var(--surface-rule); + margin-top: var(--space-4); + padding-top: var(--space-3); +} + +.reply-action { + max-width: 100%; +} + +.reply-action > summary.button { + list-style: none; +} + +.reply-action > summary.button::-webkit-details-marker { + display: none; +} + +.reply-action[open] { + flex: 1 1 100%; +} + +.reply-action .composer { + margin-top: var(--space-4); +} + +.discussion-entry * { + max-width: 100%; +} diff --git a/public/static/css/components/formatting-help.css b/public/static/css/components/formatting-help.css new file mode 100644 index 0000000..affd53e --- /dev/null +++ b/public/static/css/components/formatting-help.css @@ -0,0 +1,22 @@ +.formatting-help { + display: grid; + gap: var(--space-4); +} + +.formatting-help > :where(h1, h2, h3, p) { + margin: 0; +} + +.formatting-help__examples { + display: grid; + gap: var(--space-2); +} + +.formatting-help code { + background: var(--color-field); + border: var(--border-thin) solid var(--color-field-border); + display: block; + font-size: var(--font-size-small); + overflow-wrap: anywhere; + padding: var(--space-2); +} diff --git a/public/static/css/components/forms.css b/public/static/css/components/forms.css new file mode 100644 index 0000000..0c4ea72 --- /dev/null +++ b/public/static/css/components/forms.css @@ -0,0 +1,170 @@ +.form-stack { + display: grid; + gap: var(--space-7); + max-width: 100%; +} + +.form-stack.inline-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + justify-content: center; +} + +.form-stack.inline-actions :where(button, .button) { + width: auto; +} + +.search-form { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + max-width: 100%; +} + +.search-form input { + flex: 1 1 12rem; + min-width: 0; +} + +.search-form button { + flex: 0 0 auto; +} + +.form-message { + align-items: flex-start; + background: var(--surface-background-soft); + border: var(--border-thin) solid var(--surface-border-soft); + border-radius: var(--radius-panel); + color: var(--color-text); + display: flex; + gap: var(--space-4); + margin: 0; + max-width: 100%; + padding: var(--space-4) var(--space-5); + text-align: left; +} + +.form-stack > .form-message, +.composer > .form-message { + margin: 0; +} + +.form-error { + background: var(--color-danger-soft); + border-color: var(--color-danger-border); + color: var(--color-danger-text); +} + +.form-success { + background: var(--color-success-soft); + border-color: var(--color-success-border); + color: var(--color-success-text); +} + +.form-message--error::before, +.form-message--success::before { + flex: 0 0 auto; + font-weight: bold; + line-height: var(--line-height-body); +} + +.form-message--error, +.form-message--success { + align-items: center; + justify-content: center; + text-align: center; +} + +.form-message--error::before { + color: var(--color-danger); + content: "\00d7"; +} + +.form-message--success::before { + color: var(--color-success-text); + content: "\2713"; +} + +.raid-banner::before { + content: none; +} + +.raid-banner { + display: block; + text-align: center; +} + +.raid-banner .icon { + color: var(--color-danger); + display: inline-flex; + margin-inline-end: var(--icon-label-gap); +} + +.raid-banner > span:not(.icon) { + display: inline; +} + +.form-field { + display: grid; + gap: var(--space-3); + max-width: 100%; +} + +.form-field__label { + font-weight: bold; +} + +.form-field__label-link { + font-size: var(--font-size-small); + font-weight: normal; + margin-inline-start: var(--space-4); +} + +.form-field__hint, +.form-actions__hint { + color: var(--color-text-muted); + font-size: var(--font-size-small); +} + +.form-field > :where(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="reset"]):not([type="submit"]):not([type="image"]), textarea, select) { + width: 100%; +} + +.form-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-4); +} + +.form-actions__hint { + align-items: center; + display: inline-flex; + min-height: var(--control-min-height); +} + +.form-options { + display: grid; + gap: var(--space-5); +} + +.form-checks { + display: grid; + gap: var(--space-3); +} + +.form-checks label { + align-items: center; + display: inline-flex; + gap: var(--space-3); +} + +.character-limit-hint { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-style: italic; + line-height: var(--line-height-body); +} diff --git a/public/static/css/components/groups.css b/public/static/css/components/groups.css new file mode 100644 index 0000000..af11905 --- /dev/null +++ b/public/static/css/components/groups.css @@ -0,0 +1,84 @@ +.community-panel__body { + align-items: start; + display: grid; + gap: var(--space-6); + padding: var(--space-6) var(--space-7) var(--space-7); +} + +.community-panel__summary { + min-width: 0; +} + +.community-panel__list { + display: flex; + flex-wrap: wrap; + gap: var(--space-5); + min-width: 0; +} + +.community-panel__list--single-line { + flex-wrap: nowrap; + overflow: hidden; +} + +.community-panel p { + margin: 0; + padding: 0; +} + +.community-panel__more { + flex: 0 0 auto; + font-size: var(--font-size-caption); + text-align: right; +} + +.community-card { + background: var(--color-brand-soft); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + flex: 0 1 calc((100% - (var(--space-5) * 2)) / 3); + gap: var(--space-5); + min-width: 0; + padding: var(--space-6); +} + +.community-card__name { + color: var(--color-brand); + font-size: var(--font-size-card-heading); + line-height: var(--line-height-body); + margin: 0; + overflow-wrap: anywhere; +} + +.community-card__description { + color: var(--color-text); + display: -webkit-box; + line-height: var(--line-height-body); + min-width: 0; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +@media (max-width: 48em) { + .community-card { + flex-basis: calc((100% - var(--space-5)) / 2); + } + + .community-panel__list--single-line > .community-card:nth-child(n + 3) { + display: none; + } +} + +@media (max-width: 30em) { + .community-card { + flex-basis: 100%; + } + + .community-panel__list--single-line > .community-card { + flex-basis: calc((100% - var(--space-5)) / 2); + } +} diff --git a/public/static/css/components/links.css b/public/static/css/components/links.css new file mode 100644 index 0000000..28ab396 --- /dev/null +++ b/public/static/css/components/links.css @@ -0,0 +1,66 @@ +.link-list { + list-style: none; + margin: 0; + padding: 0; +} + +.link-list li { + align-items: center; + display: inline-flex; +} + +.link-list li + li::before { + color: var(--link-separator-color, var(--color-text)); + content: "|"; + margin: 0 var(--link-separator-gap); +} + +.inline-links { + display: inline; +} + +.inline-links__item { + white-space: nowrap; +} + +.inline-links__item + .inline-links__item::before { + color: var(--link-separator-color, var(--color-text)); + content: "|"; + display: inline-block; + margin: 0 var(--link-separator-gap); + text-decoration: none; +} + +.social-link-label { + align-items: center; + display: inline-flex; + gap: var(--space-2); +} + +.social-link-icon { + display: block; + flex: 0 0 auto; + height: var(--icon-size); + width: var(--icon-size); +} + +.page-back-link { + color: var(--color-text-muted); + font-size: var(--font-size-small); + line-height: 1.1; +} + +.page-back-link a { + align-items: center; + display: inline-flex; + gap: var(--space-2); +} + +.page-back-link__arrow { + line-height: 1; +} + +.page-frame > .page-back-link:first-child { + margin-bottom: var(--space-2); + margin-top: var(--space-6); +} diff --git a/public/static/css/components/metadata.css b/public/static/css/components/metadata.css new file mode 100644 index 0000000..d45380e --- /dev/null +++ b/public/static/css/components/metadata.css @@ -0,0 +1,12 @@ +.preview-title { + font-weight: bold; +} + +.timestamp { + color: var(--color-text-muted); + font-style: italic; +} + +.meta-subject { + font-weight: bold; +} diff --git a/public/static/css/components/pagination.css b/public/static/css/components/pagination.css new file mode 100644 index 0000000..3de6597 --- /dev/null +++ b/public/static/css/components/pagination.css @@ -0,0 +1,8 @@ +.pagination { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: center; + margin: var(--space-7) 0 0; +} diff --git a/public/static/css/components/panels.css b/public/static/css/components/panels.css new file mode 100644 index 0000000..b94e69b --- /dev/null +++ b/public/static/css/components/panels.css @@ -0,0 +1,74 @@ +.panel { + --panel-body-background: var(--color-surface); + --panel-border-color: var(--color-brand-accent); + --panel-border-width: var(--border-medium); + --panel-heading-background: var(--color-brand-accent); + --panel-heading-border: var(--panel-border-color); + --panel-heading-color: var(--color-text-on-bright); + + background: var(--panel-body-background); + border: var(--panel-border-width) solid var(--panel-border-color); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + overflow: hidden; + width: 100%; +} + +.panel--soft { + --panel-border-color: var(--color-panel-accent); + --panel-border-width: var(--border-thin); + --panel-heading-background: var(--color-panel-accent); + --panel-heading-border: var(--color-panel-rule); + --panel-heading-color: var(--color-brand); +} + +.panel--strong { + --panel-body-background: var(--color-surface); +} + +.panel__heading { + align-items: baseline; + background: var(--panel-heading-background); + border-bottom: var(--border-thin) solid var(--panel-heading-border); + color: var(--panel-heading-color); + display: grid; + gap: var(--space-6); + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + min-width: 0; + padding: var(--space-1) var(--space-4); +} + +.panel__heading h1, +.panel__heading h2, +.panel__heading h3, +.panel__heading h4 { + display: inline-block; + font-size: var(--font-size-panel-heading); + grid-column: 2; + justify-self: center; + line-height: var(--line-height-body); + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + text-align: center; +} + +.panel__body { + align-content: start; + background: var(--panel-body-background); + display: grid; + gap: var(--panel-body-gap); + padding: var(--space-4); +} + +.panel__body > * { + margin-block: 0; +} + +.panel__action { + flex: 0 0 auto; + font-size: var(--font-size-caption); + grid-column: 3; + justify-self: end; + text-align: right; +} diff --git a/public/static/css/components/people.css b/public/static/css/components/people.css new file mode 100644 index 0000000..8f50d97 --- /dev/null +++ b/public/static/css/components/people.css @@ -0,0 +1,177 @@ +.people-panel__body { + align-items: start; + display: grid; + gap: var(--space-6); + padding: var(--space-6) var(--space-7) var(--space-7); +} + +.people-panel__summary { + min-width: 0; +} + +.people-panel__list { + display: flex; + flex-wrap: wrap; + gap: var(--space-6); + min-width: 0; +} + +.people-panel__list--single-line { + flex-wrap: nowrap; + gap: 0; + justify-content: space-evenly; + overflow: hidden; +} + +.people-panel__list--single-line > .person-card { + flex: 0 0 var(--person-card-width); +} + +@supports selector(:has(*)) { + .people-panel__list--single-line:has(> .person-card:only-child) { + justify-content: center; + } +} + +@supports (container-type: inline-size) { + .people-panel__body { + container-type: inline-size; + } + + .people-panel__list--single-line > .person-card { + display: none; + } + + .people-panel__list--single-line > .person-card:nth-child(1) { + display: inline-block; + } + + @container (min-width: 170px) { + .people-panel__list--single-line > .person-card:nth-child(-n + 2) { + display: inline-block; + } + } + + @container (min-width: 260px) { + .people-panel__list--single-line > .person-card:nth-child(-n + 3) { + display: inline-block; + } + } + + @container (min-width: 350px) { + .people-panel__list--single-line > .person-card:nth-child(-n + 4) { + display: inline-block; + } + } + + @container (min-width: 440px) { + .people-panel__list--single-line > .person-card:nth-child(-n + 5) { + display: inline-block; + } + } + + @container (min-width: 530px) { + .people-panel__list--single-line > .person-card:nth-child(-n + 6) { + display: inline-block; + } + } +} + +.people-panel p { + margin: 0; + padding: 0; +} + +.people-panel__more { + flex: 0 0 auto; + font-size: var(--font-size-caption); + text-align: right; +} + +.person-card { + align-self: flex-start; + display: inline-block; + margin: 0; + min-height: var(--person-card-min-height); + text-align: center; + width: var(--person-card-width); +} + +.person-card__identity { + display: block; +} + +.person-card__identity--link { + color: var(--color-link); + cursor: pointer; +} + +.person-card__identity--disabled { + color: var(--color-text); + cursor: default; +} + +.person-card__name { + font-weight: bold; + overflow-wrap: break-word; + text-align: center; +} + +.person-card__image { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-media); + box-shadow: var(--shadow-photo); + display: block; + margin: 0 auto var(--space-3); + max-height: var(--avatar-size); + max-width: var(--person-card-width); + object-fit: cover; +} + +.person-card .profile-placeholder { + display: flex; + margin: 0 auto var(--space-3); + max-height: var(--avatar-size); + max-width: var(--person-card-width); +} + +.person-list-card { + align-items: center; + background: var(--surface-background-tint); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: flex; + flex-wrap: wrap; + gap: var(--space-6); + justify-content: space-between; + margin: 0; + padding: var(--space-5); + width: 100%; +} + +.person-list-card__identity { + align-items: center; + display: inline-flex; + gap: var(--space-5); + min-width: 0; +} + +.person-list-card__name { + font-weight: bold; + overflow-wrap: anywhere; +} + +.person-list-card__actions, +.person-list-card__actions form { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-4); +} + +.person-list-card__actions { + justify-content: flex-end; + margin-left: auto; +} diff --git a/public/static/css/components/posts.css b/public/static/css/components/posts.css new file mode 100644 index 0000000..11ab24b --- /dev/null +++ b/public/static/css/components/posts.css @@ -0,0 +1,136 @@ +.post-panel { + margin: 0; +} + +.post-panel--author-skin-bleed { + overflow: visible; +} + +.composer { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + margin: 0; + padding: var(--space-5); +} + +.composer input[type="text"], +.composer textarea { + max-width: 100%; + width: 100%; +} + +.composer__actions { + display: grid; + gap: var(--space-2); + margin-top: var(--space-4); +} + +.composer__controls { + align-items: center; + display: grid; + gap: var(--space-4); + grid-template-columns: minmax(0, 1fr) auto; +} + +.composer__controls input[type="file"] { + min-width: 0; + width: 100%; +} + +.composer__controls button { + white-space: nowrap; +} + +.composer__limit { + display: block; + justify-self: end; + text-align: right; +} + +.composer--reply { + background: var(--surface-background); + border-color: var(--surface-rule); + margin: 0; + padding: var(--space-4); +} + +.post-list { + display: grid; + gap: var(--list-stack-gap); +} + +.post-list > * { + margin-block: 0; +} + +.post-list:has(> [data-author-skin-backdrop="container"]) { + gap: 0; +} + +.post-list:has(> [data-author-skin-backdrop="container"]) > .post-card + .post-card { + margin-top: var(--list-stack-gap); +} + +.post-card { + background: var(--surface-background); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + overflow-wrap: break-word; + padding: var(--space-5); + word-break: break-word; +} + +.post-card__bump { + border-left: var(--border-medium) solid var(--surface-rule); + color: var(--color-text-muted); + font-style: italic; + margin: 0 0 var(--space-4); + padding-left: var(--space-4); +} + +.post-card__bump small { + font-style: normal; + white-space: nowrap; +} + +.post-card__header { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: auto minmax(0, 1fr); + margin-bottom: var(--space-4); +} + +.post-card__meta p { + margin: 0; +} + +.post-card__body { + margin: var(--space-5) 0; +} + +.post-card__body > :first-child { + margin-top: 0; +} + +.post-card__media { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-media); + display: block; + height: auto; + margin: var(--space-5) 0; + max-width: 100%; +} + +.post-card__actions { + border-top: var(--border-thin) solid var(--surface-rule); + margin-top: var(--space-5); + padding-top: var(--space-4); +} + +.post-comments { + margin-top: 0; +} diff --git a/public/static/css/components/tables.css b/public/static/css/components/tables.css new file mode 100644 index 0000000..6a6eb74 --- /dev/null +++ b/public/static/css/components/tables.css @@ -0,0 +1,50 @@ +.details-table, +.listing-table { + overflow-wrap: break-word; + width: 100%; + word-break: break-word; +} + +.details-table td:first-child { + background: var(--color-brand-soft); + border-right: var(--border-thin) solid var(--surface-border); + color: var(--color-brand); + font-weight: bold; + width: var(--details-label-width); +} + +.details-table td { + background: var(--surface-background-raised); + padding: var(--table-cell-y) var(--table-cell-x); + vertical-align: top; +} + +.details-table td p { + margin: 0; +} + +.listing-table { + border-collapse: collapse; + border-spacing: 0; +} + +.listing-table th, +.listing-table td { + border: var(--border-thin) solid var(--color-text); + padding: var(--table-cell-y) var(--table-cell-x); + text-align: center; + vertical-align: top; +} + +.listing-table th { + background: var(--color-brand-soft); + color: var(--color-brand); +} + +.listing-table tr:nth-child(even) td { + background: var(--color-row-alt); +} + +.listing-table td * { + max-width: 100%; +} diff --git a/public/static/css/core/base.css b/public/static/css/core/base.css new file mode 100644 index 0000000..a432333 --- /dev/null +++ b/public/static/css/core/base.css @@ -0,0 +1,253 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--color-page); + background-image: var(--page-background); + color: var(--color-text); + font-family: var(--font-body); + line-height: var(--line-height-body); +} + +@supports (padding-top: env(safe-area-inset-top)) { + body { + background: + linear-gradient(to bottom, var(--color-brand-header) env(safe-area-inset-top, 0px), transparent 0), + var(--page-background), + var(--color-page); + } +} + +a { + color: var(--color-link); + text-decoration: none; + text-decoration-thickness: var(--text-decoration-hairline); + text-underline-offset: var(--text-underline-offset); +} + +a:hover, +a:active { + color: var(--color-link-hover); + text-decoration: underline; +} + +p, +li { + font-size: var(--font-size-small); +} + +img { + max-width: 100%; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +:where(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="reset"]):not([type="submit"]):not([type="image"])), +:where(textarea), +:where(select) { + border-radius: var(--radius-subtle); +} + +:where(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="reset"]):not([type="submit"]):not([type="image"])), +:where(textarea), +:where(select) { + background: var(--color-field); + border: var(--border-thin) solid var(--color-field-border); + border-color: var(--color-field-border-dark) var(--color-field-border-light) var(--color-field-border-light) var(--color-field-border-dark); + box-shadow: var(--shadow-field); + max-width: 100%; + min-height: var(--control-min-height); + padding: var(--control-pad-y) var(--control-pad-x); +} + +input[type="file"] { + max-width: 100%; +} + +input:disabled, +input[readonly], +textarea:disabled, +select:disabled { + background: var(--color-field-disabled); + color: var(--color-text-muted); +} + +button, +input[type="button"], +input[type="reset"], +input[type="submit"], +.button { + --button-bg: var(--color-button-primary-bg); + --button-text: var(--color-button-primary-text); + --button-border-light: var(--color-button-primary-border-light); + --button-border-dark: var(--color-button-primary-border-dark); + --button-hover-bg: var(--color-button-primary-hover-bg); + --button-hover-text: var(--color-button-primary-hover-text); + + align-items: center; + background: var(--button-bg); + border: var(--border-thin) solid var(--button-border-dark); + border-color: var(--button-border-light) var(--button-border-dark) var(--button-border-dark) var(--button-border-light); + border-radius: var(--radius-subtle); + box-shadow: var(--shadow-button); + color: var(--button-text); + cursor: pointer; + display: inline-flex; + justify-content: center; + min-height: var(--control-min-height); + padding: var(--control-pad-y) var(--control-pad-x); + text-align: center; + text-decoration: none; +} + +button:hover, +input[type="button"]:hover, +input[type="reset"]:hover, +input[type="submit"]:hover, +.button:hover { + background: var(--button-hover-bg); + color: var(--button-hover-text); + text-decoration: none; +} + +button:active, +input[type="button"]:active, +input[type="reset"]:active, +input[type="submit"]:active, +.button:active { + box-shadow: var(--shadow-pressed); + transform: translate(var(--button-active-shift), var(--button-active-shift)); +} + +.button--secondary { + --button-bg: var(--color-button-secondary-bg); + --button-text: var(--color-button-secondary-text); + --button-border-light: var(--color-button-secondary-border-light); + --button-border-dark: var(--color-button-secondary-border-dark); + --button-hover-bg: var(--color-button-secondary-hover-bg); + --button-hover-text: var(--color-button-secondary-hover-text); +} + +.button--danger { + --button-bg: var(--color-button-danger-bg); + --button-text: var(--color-button-danger-text); + --button-border-light: var(--color-button-danger-border-light); + --button-border-dark: var(--color-button-danger-border-dark); + --button-hover-bg: var(--color-button-danger-hover-bg); + --button-hover-text: var(--color-button-danger-hover-text); +} + +.button--selected { + --button-bg: var(--color-button-primary-bg); + --button-text: var(--color-button-primary-text); + --button-border-light: var(--color-button-primary-border-light); + --button-border-dark: var(--color-button-primary-border-dark); + --button-hover-bg: var(--color-button-primary-hover-bg); + --button-hover-text: var(--color-button-primary-hover-text); +} + +button:disabled, +input[type="button"]:disabled, +input[type="reset"]:disabled, +input[type="submit"]:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); + transform: none; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: var(--focus-outline-width) solid var(--color-focus); + outline-offset: var(--focus-offset); +} + +a:focus-visible, +summary:focus-visible { + background: var(--color-link-focus-bg); +} + +textarea { + min-width: 0; + width: 100%; + max-width: 100%; + resize: vertical; +} + +.text-editor { + min-height: 18rem; + resize: vertical; + white-space: pre-wrap; +} + +.text-editor--short { + min-height: 7rem; +} + +.text-block { + max-width: 100%; + overflow: auto; + white-space: break-spaces; +} + +table { + max-width: 100%; +} + +audio { + max-width: 100%; + width: var(--audio-width); +} + +.flush-heading { + margin: 0; +} + +.inline-form { + display: inline; +} + +.icon { + display: inline-flex; + flex: 0 0 auto; + margin: 0; + vertical-align: var(--icon-offset-y); +} + +:where(a, button, span, summary):has(> .icon) { + align-items: center; + display: inline-flex; + gap: var(--icon-label-gap); +} + +.icon svg { + display: block; + height: var(--icon-size); + stroke: currentColor; + width: var(--icon-size); +} + +.count { + color: var(--count-color, var(--color-link)); +} + +a:hover .count, +a:active .count { + color: inherit; +} diff --git a/public/static/css/core/flow.css b/public/static/css/core/flow.css new file mode 100644 index 0000000..a198845 --- /dev/null +++ b/public/static/css/core/flow.css @@ -0,0 +1,10 @@ +.page-frame > *, +.split-layout__pane > * { + margin-block: 0; +} + +.page-frame > .page-heading:first-child, +.page-frame > h1:first-child, +.page-frame > .post-card:first-child { + margin-top: var(--space-6); +} diff --git a/public/static/css/core/layout.css b/public/static/css/core/layout.css new file mode 100644 index 0000000..dd2a0db --- /dev/null +++ b/public/static/css/core/layout.css @@ -0,0 +1,82 @@ +.master-container { + background: var(--color-canvas); + border-left: var(--border-thin) solid var(--color-container-border); + border-right: var(--border-thin) solid var(--color-container-border); + display: flex; + flex-direction: column; + margin: auto; + max-width: 100%; + min-height: var(--container-min-height); + width: var(--container-width); +} + +main { + background: var(--color-canvas); + flex: 1 0 auto; + font-size: var(--font-size-main); + overflow-wrap: break-word; + padding: var(--space-main-y) var(--space-main-x); +} + +.split-layout { + align-items: start; + min-width: 0; +} + +.split-layout--messages { + display: grid; + gap: var(--layout-stack-gap); +} + +.split-layout__pane { + align-content: start; + display: grid; + gap: var(--layout-stack-gap); + min-width: 0; + padding: var(--space-6); +} + +.split-layout--messages > .split-layout__pane { + padding: 0; +} + +.page-frame { + align-content: start; + display: grid; + gap: var(--layout-stack-gap); + margin: 0 auto var(--space-11); + max-width: 100%; + overflow-wrap: break-word; + padding: 0 var(--space-5); +} + +.page-frame--narrow { + width: var(--content-measure); +} + +.page-frame--wide { + width: min(100%, calc(var(--container-width) - var(--space-10))); +} + +.page-frame--full { + width: 100%; +} + +.page-heading { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-5); + justify-content: space-between; + min-width: 0; +} + +.page-heading__title { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.page-heading__actions { + flex: 0 0 auto; +} diff --git a/public/static/css/core/tokens.css b/public/static/css/core/tokens.css new file mode 100644 index 0000000..72dfd77 --- /dev/null +++ b/public/static/css/core/tokens.css @@ -0,0 +1,220 @@ +:root { + color-scheme: light; + + --color-white: #ffffff; + --color-ink: #1a1a1a; + + /* App theme values written by admin branding. Profile skins use these as fallbacks. */ + --app-theme-chrome: #7c3aed; + --app-theme-chrome-text: var(--color-white); + --app-theme-accent: #7c3aed; + --app-theme-accent-text: var(--color-white); + --app-theme-link: #6b21a8; + --app-theme-backdrop: #e6e3ea; + --app-theme-page: #ffffff; + --app-theme-surface: #ffffff; + --app-theme-page-text: var(--color-ink); + --app-theme-surface-text: var(--app-theme-page-text); + --app-theme-link-hover: color-mix(in srgb, var(--app-theme-link) 68%, var(--app-theme-page-text)); + --app-theme-surface-link: var(--app-theme-link); + --app-theme-surface-link-hover: var(--app-theme-link-hover); + --app-theme-muted: #6b7280; + --app-theme-focus: #f59e0b; + + /* Theme source tokens for the app shell and component palette. */ + --theme-chrome: var(--app-theme-chrome); + --theme-chrome-text: var(--app-theme-chrome-text); + --theme-accent: var(--app-theme-accent); + --theme-accent-text: var(--app-theme-accent-text); + --theme-link: var(--app-theme-link); + --theme-backdrop: var(--app-theme-backdrop); + --theme-page: var(--app-theme-page); + --theme-surface: var(--app-theme-surface); + --theme-page-text: var(--app-theme-page-text); + --theme-surface-text: var(--theme-page-text); + --theme-link-hover: color-mix(in srgb, var(--theme-link) 68%, var(--theme-page-text)); + --theme-surface-link: var(--theme-link); + --theme-surface-link-hover: var(--theme-link-hover); + --theme-muted: var(--app-theme-muted); + --theme-danger: #d32626; + --theme-danger-text: var(--theme-danger); + --theme-success: #34d399; + --theme-success-ink: var(--color-ink); + --theme-success-text: #047857; + --theme-focus: var(--app-theme-focus); + + /* Background texture for the default app shell. */ + --theme-page-grid-a: color-mix(in srgb, var(--theme-backdrop) 88%, white); + --theme-page-grid-b: color-mix(in srgb, var(--theme-backdrop) 94%, black); + + --theme-toggle-dark-display: inline-flex; + --theme-toggle-light-display: none; + + --font-body: Verdana, Arial, Helvetica, sans-serif; + --line-height-body: 1.225; + --font-size-main: 80%; + --font-size-footer: 70%; + --font-size-note: 90%; + --font-size-caption: 80%; + --font-size-small: max(0.875em, 12px); + --font-size-nav: 10pt; + --font-size-nav-link: max(0.98em, 12px); + --font-size-utility-action: max(var(--font-size-caption), 12px); + --font-size-card-heading: 1.17em; + --font-size-panel-heading: 1.06em; + --font-size-profile-heading: 1.5em; + + --container-width: 960px; + --container-min-height: 100dvh; + --content-measure: 600px; + --nav-side-min: 130px; + --audio-width: 190px; + --avatar-size: 80px; + --avatar-compact-size: 48px; + --profile-photo-size: 235px; + --profile-edit-photo-size: 180px; + --person-card-width: var(--avatar-size); + --person-card-min-height: 100px; + --profile-person-card-width: 105px; + --details-label-width: 33%; + --discussion-author-width: calc(var(--avatar-compact-size) + var(--space-12)); + --column-compact: 20%; + --column-profile: 40%; + --column-wide: 60%; + --column-aside-min: 9rem; + --profile-sidebar-min: calc(var(--profile-photo-size) + (var(--space-6) * 2)); + + --space-1: 2px; + --space-2: 4px; + --space-3: 5px; + --space-4: 7px; + --space-5: 8px; + --space-6: 10px; + --space-7: 12px; + --space-8: 14px; + --space-9: 15px; + --space-10: 20px; + --space-11: 30px; + --space-12: 40px; + --layout-stack-gap: var(--space-8); + --panel-body-gap: var(--space-7); + --list-stack-gap: var(--space-7); + --space-main-y: 0; + --space-main-x: 0; + --space-nav-links-y: 6px; + --space-nav-links-x: 16px; + --link-separator-gap: 0.45em; + --article-title-offset: 3px; + --icon-label-gap: 0.35em; + --icon-offset-y: -0.22em; + + --border-thin: 1px; + --border-medium: 2px; + --radius-panel-default: 0; + --radius-media-default: 0; + --radius-panel: var(--radius-panel-default); + --radius-media: var(--radius-media-default); + --radius-control-default: 2px; + --radius-subtle: var(--radius-control-default); + --text-decoration-hairline: 0.5px; + --text-underline-offset: 2px; + --icon-size: 1.2em; + --brand-icon-size: 2em; + --avatar-glyph-size: 70%; + --control-min-height: 24px; + --control-pad-y: var(--space-1); + --control-pad-x: var(--space-3); + --button-active-shift: 1px; + --disabled-opacity: 0.68; + --focus-outline-width: 2px; + --focus-offset: 1px; + --table-cell-y: var(--space-3); + --table-cell-x: 6px; + --contact-cell-min-height: 32px; + --nav-search-width: 170px; + --nav-link-min-height: 24px; +} + +:root, +[data-skin-page], +[data-author-skin-page] { + /* Shared resolver: admin themes and profile skins both feed --theme-* here. */ + --color-page: var(--theme-backdrop); + --color-canvas: var(--theme-page); + --color-surface: var(--theme-surface); + --color-surface-raised: color-mix(in srgb, var(--color-surface) 94%, var(--color-brand-accent)); + --color-surface-tint: color-mix(in srgb, var(--color-surface) 90%, var(--color-brand-accent)); + --color-text: var(--theme-page-text); + --color-text-muted: var(--theme-muted); + --color-text-on-bright: var(--theme-chrome-text); + --color-link: var(--theme-link); + --color-link-hover: var(--theme-link-hover); + --color-brand: var(--theme-surface-link); + --color-brand-accent: var(--theme-chrome); + --color-brand-header: color-mix(in srgb, var(--color-brand-accent) 72%, black); + --color-brand-nav: color-mix(in srgb, var(--color-brand-accent) 88%, black); + --color-brand-soft: color-mix(in srgb, var(--color-surface) 84%, var(--color-brand-accent)); + --color-brand-border: color-mix(in srgb, var(--color-white) 54%, var(--color-brand-accent)); + --color-panel-accent: var(--color-brand-soft); + --color-panel-accent-soft: color-mix(in srgb, var(--color-surface) 94%, var(--color-brand-accent)); + --color-panel-rule: color-mix(in srgb, var(--color-brand-border) 72%, var(--color-white)); + --color-container-border: color-mix(in srgb, var(--color-page) 70%, var(--color-brand-accent)); + --color-danger: var(--theme-danger); + --color-danger-text: var(--theme-danger-text); + --color-danger-soft: color-mix(in srgb, var(--color-surface) 88%, var(--color-danger)); + --color-danger-border: color-mix(in srgb, var(--color-white) 52%, var(--color-danger)); + --color-success: var(--theme-success); + --color-success-ink: var(--theme-success-ink); + --color-success-text: var(--theme-success-text); + --color-success-soft: color-mix(in srgb, var(--color-surface) 86%, var(--color-success)); + --color-success-border: color-mix(in srgb, var(--color-white) 52%, var(--color-success)); + --color-field: color-mix(in srgb, var(--color-surface) 98%, var(--color-canvas)); + --color-field-disabled: color-mix(in srgb, var(--color-surface) 88%, var(--color-text)); + --color-field-border-light: var(--color-white); + --color-field-border: color-mix(in srgb, var(--color-ink) 42%, var(--color-white)); + --color-field-border-dark: color-mix(in srgb, var(--color-ink) 62%, var(--color-white)); + --color-focus: var(--theme-focus); + --color-link-focus-bg: var(--color-surface-tint); + --color-row-alt: var(--color-surface-tint); + --color-shadow-panel: color-mix(in srgb, var(--color-surface) 70%, var(--color-page)); + --color-shadow-soft: color-mix(in srgb, var(--color-white) 70%, var(--color-ink)); + --color-shadow-pressed: color-mix(in srgb, var(--color-ink) 55%, black); + --color-shadow-card: color-mix(in srgb, var(--color-surface) 68%, var(--color-page)); + --color-button-primary-bg: var(--theme-accent); + --color-button-primary-text: var(--theme-accent-text); + --color-button-primary-border-light: color-mix(in srgb, var(--color-white) 54%, var(--color-button-primary-bg)); + --color-button-primary-border-dark: color-mix(in srgb, var(--color-button-primary-bg) 68%, black); + --color-button-primary-hover-bg: var(--color-button-primary-border-dark); + --color-button-primary-hover-text: var(--color-button-primary-text); + --color-button-secondary-bg: var(--color-surface); + --color-button-secondary-text: var(--color-brand); + --color-button-secondary-border-light: var(--color-surface-tint); + --color-button-secondary-border-dark: var(--color-brand-border); + --color-button-secondary-hover-bg: var(--color-surface-tint); + --color-button-secondary-hover-text: var(--theme-surface-link-hover); + --color-button-danger-bg: var(--color-surface); + --color-button-danger-text: var(--color-danger-text); + --color-button-danger-border-light: var(--color-danger-border); + --color-button-danger-border-dark: var(--color-danger); + --color-button-danger-hover-bg: var(--color-danger-soft); + --color-button-danger-hover-text: var(--color-danger); + + --surface-background: var(--color-surface); + --surface-background-raised: var(--color-surface-raised); + --surface-background-soft: var(--color-panel-accent-soft); + --surface-background-tint: var(--color-surface-tint); + --surface-border: var(--color-brand-border); + --surface-border-soft: var(--color-panel-accent); + --surface-rule: var(--color-panel-rule); + --surface-shadow: var(--shadow-panel); + + --page-background: repeating-linear-gradient(0deg, var(--theme-page-grid-a) 0, var(--theme-page-grid-a) var(--border-thin), var(--theme-page-grid-b) var(--border-thin), var(--theme-page-grid-b) var(--border-medium)); + --shadow-panel: var(--border-thin) var(--border-thin) 0 var(--color-white), var(--border-medium) var(--border-medium) 0 var(--color-shadow-panel); + --shadow-button: inset var(--border-thin) var(--border-thin) 0 var(--button-border-light), var(--border-thin) var(--border-thin) 0 var(--button-border-dark); + --shadow-field: inset var(--border-thin) var(--border-thin) var(--border-thin) var(--color-shadow-soft); + --shadow-pressed: inset var(--border-thin) var(--border-thin) var(--border-thin) var(--color-shadow-pressed); + --shadow-photo: var(--border-medium) var(--border-medium) 0 var(--color-shadow-soft); + --shadow-card: var(--border-medium) var(--border-medium) 0 var(--color-shadow-card); + --column-divider-shadow: inset calc(var(--border-thin) * -1) 0 0 var(--color-surface), inset calc(var(--border-medium) * -1) 0 0 var(--color-panel-accent-soft); + accent-color: var(--color-brand); +} diff --git a/public/static/css/features/profile.css b/public/static/css/features/profile.css new file mode 100644 index 0000000..b5a4700 --- /dev/null +++ b/public/static/css/features/profile.css @@ -0,0 +1,517 @@ +[data-author-skin-page] { + --skin-palette-accent: initial; + --skin-palette-accent-text: initial; + --skin-palette-backdrop: initial; + --skin-palette-chrome: initial; + --skin-palette-chrome-text: initial; + --skin-palette-focus: initial; + --skin-palette-link: initial; + --skin-palette-link-hover: initial; + --skin-palette-muted: initial; + --skin-palette-page: initial; + --skin-palette-page-text: initial; + --skin-palette-surface: initial; + --skin-palette-surface-link-hover: initial; + --skin-palette-surface-text: initial; +} + +[data-skin-page], +[data-author-skin-page] { + --skin-accent: var(--skin-palette-accent, var(--app-theme-accent)); + --skin-accent-text: var(--skin-palette-accent-text, var(--app-theme-accent-text)); + --skin-backdrop: var(--skin-palette-backdrop, var(--app-theme-backdrop)); + --skin-background: var(--skin-palette-page, var(--app-theme-page)); + --skin-link: var(--skin-palette-link, var(--app-theme-surface-link)); + --skin-panel-background: var(--skin-palette-surface, var(--app-theme-surface)); + --skin-panel-heading-background: var(--skin-palette-chrome, var(--app-theme-chrome)); + --skin-panel-heading-text: var(--skin-palette-chrome-text, var(--app-theme-chrome-text)); + --skin-panel-text: var(--skin-palette-surface-text, var(--app-theme-surface-text)); + --skin-radius: 0; + --skin-radius-control: var(--radius-control-default); + --skin-radius-panel: var(--skin-radius); + --skin-radius-photo: var(--skin-radius); + --skin-text: var(--skin-palette-page-text, var(--app-theme-page-text)); + --skin-link-hover: var(--skin-palette-link-hover, var(--app-theme-link-hover)); + --skin-surface-link-hover: var(--skin-palette-surface-link-hover, var(--app-theme-surface-link-hover)); + --skin-muted: var(--skin-palette-muted, var(--app-theme-muted)); + --skin-focus: var(--skin-palette-focus, var(--app-theme-focus)); + + --theme-backdrop: var(--skin-backdrop); + --theme-page: var(--skin-background); + --theme-surface: var(--skin-panel-background); + --theme-chrome: var(--skin-panel-heading-background); + --theme-chrome-text: var(--skin-panel-heading-text); + --theme-accent: var(--skin-accent); + --theme-accent-text: var(--skin-accent-text); + --theme-link: var(--skin-link); + --theme-link-hover: var(--skin-link-hover); + --theme-surface-link: var(--skin-link); + --theme-surface-link-hover: var(--skin-surface-link-hover); + --theme-page-text: var(--skin-text); + --theme-surface-text: var(--skin-panel-text); + --theme-muted: var(--skin-muted); + --theme-focus: var(--skin-focus); + --theme-page-grid-a: color-mix(in srgb, var(--theme-backdrop) 88%, white); + --theme-page-grid-b: color-mix(in srgb, var(--theme-backdrop) 94%, black); + --radius-panel: var(--skin-radius-panel); + --radius-media: var(--skin-radius-photo); + --radius-subtle: var(--skin-radius-control); + accent-color: var(--theme-accent); +} + +:where([data-author-skin-layer="page"]) { + --author-skin-backdrop-inline-size: 100vw; + --author-skin-backdrop-padding: 0px; + --author-skin-shell-inline-size: min(100vw, var(--container-width)); + --author-skin-container-inline-size: max( + 0px, + calc(var(--author-skin-shell-inline-size) - var(--border-thin) - var(--border-thin)) + ); + --author-skin-item-inline-size: 100%; + + border: 0; + border-radius: var(--radius-panel); + box-shadow: none; + background-color: var(--color-page); + background-image: var(--page-background); + color: var(--color-text); + display: grid; + inline-size: 100%; + isolation: isolate; + margin: 0; + max-inline-size: 100%; + min-block-size: 0; + min-inline-size: 0; + overflow: hidden; + padding: 0; + position: relative; +} + +:where([data-author-skin-backdrop="item"]) { + --author-skin-backdrop-padding: var(--space-5); +} + +:where([data-author-skin-backdrop="none"]) { + background: transparent; + border-radius: 0; + overflow: visible; +} + +:where([data-author-skin-backdrop="container"]) { + inline-size: var(--author-skin-backdrop-inline-size); + margin-inline: calc((100% - var(--author-skin-backdrop-inline-size)) / 2); + max-inline-size: var(--author-skin-backdrop-inline-size); +} + +:where( + [data-author-skin-layer="backdrop"], + [data-author-skin-layer="shell"], + [data-author-skin-layer="content"], + [data-author-skin-layer="root"] +) { + block-size: auto; + display: block; + inline-size: auto; + inset: 0 !important; + margin: 0; + max-block-size: none; + max-inline-size: none; + min-block-size: 0; + min-inline-size: 0; + overflow: hidden; + padding: 0; + pointer-events: none !important; + position: absolute !important; +} + +:where([data-author-skin-layer="backdrop"]) { + background-color: var(--color-page); + background-image: var(--page-background); + z-index: 0; +} + +:where([data-author-skin-layer="shell"]) { + border-left: var(--border-thin) solid var(--color-container-border); + border-right: var(--border-thin) solid var(--color-container-border); + background: var(--color-canvas); + z-index: 1; +} + +:where([data-author-skin-layer="content"]) { + background: var(--color-canvas); + z-index: 2; +} + +:where([data-author-skin-layer="root"]) { + z-index: 3; +} + +:where( + [data-author-skin-backdrop="none"] > [data-author-skin-layer="backdrop"], + [data-author-skin-backdrop="none"] > [data-author-skin-layer="shell"], + [data-author-skin-backdrop="none"] > [data-author-skin-layer="content"], + [data-author-skin-backdrop="none"] > [data-author-skin-layer="root"] +) { + display: none; +} + +:where([data-author-skin-backdrop="container"] > [data-author-skin-layer="shell"]) { + inline-size: var(--author-skin-shell-inline-size); + inset-inline: calc((100% - var(--author-skin-shell-inline-size)) / 2) !important; +} + +:where( + [data-author-skin-backdrop="container"] > [data-author-skin-layer="content"], + [data-author-skin-backdrop="container"] > [data-author-skin-layer="root"] +) { + inline-size: var(--author-skin-container-inline-size); + inset-inline: calc((100% - var(--author-skin-container-inline-size)) / 2) !important; +} + +:where([data-author-skin-layer="foreground"]) { + display: grid; + inline-size: 100%; + justify-items: stretch; + min-block-size: 0; + min-inline-size: 0; + pointer-events: none; + position: relative; + z-index: 4; +} + +:where([data-author-skin-backdrop="item"], [data-author-skin-backdrop="container"]) :where([data-author-skin-layer="foreground"]) { + padding: var(--author-skin-backdrop-padding); +} + +:where([data-author-skin-backdrop="container"]) :where([data-author-skin-layer="foreground"]) { + justify-items: center; +} + +:where([data-author-skin-backdrop="none"] > [data-author-skin-layer="foreground"]) { + pointer-events: auto !important; +} + +[data-author-skin-frame="profile-measure"] { + display: grid; + inline-size: var(--author-skin-container-inline-size); + justify-items: center; + max-inline-size: 100%; + width: var(--author-skin-container-inline-size); +} + +[data-author-skin-frame="main"] { + inline-size: 100%; + max-inline-size: 100%; + width: 100%; +} + +:where([data-author-skin-frame="wall-body"]) { + align-content: start; + display: grid; + gap: var(--panel-body-gap); + min-inline-size: 0; + padding: var(--space-4); +} + +:where([data-author-skin-frame="wall-body"] > *) { + margin-block: 0; +} + +:where([data-author-skin-backdrop="item"], [data-author-skin-backdrop="container"]) :where([data-author-skin-part="post"], [data-author-skin-part="comment"]) { + inline-size: var(--author-skin-item-inline-size); + max-inline-size: 100%; +} + +:where([data-author-skin-part="post"], [data-author-skin-part="comment"]) { + pointer-events: auto !important; +} + +:where([data-author-skin-part="post"], [data-author-skin-part="comment"]) :where(*) { + pointer-events: auto !important; +} + +:where([data-author-skin-wrapper]) { + display: contents !important; +} + +:where([data-author-skin-scope] [data-author-skin-part="comment"] .discussion-entry__author, +[data-author-skin-scope] [data-author-skin-part="comment"] .discussion-entry__body) { + background: transparent; +} + +@media (min-width: 48em) { + [data-author-skin-frame="main"] { + inline-size: calc(100% - max(var(--profile-sidebar-min), var(--column-profile))); + width: calc(100% - max(var(--profile-sidebar-min), var(--column-profile))); + } +} + +@media (max-width: 48em) { + :where([data-author-skin-layer="page"]) { + --author-skin-backdrop-inline-size: 100%; + --author-skin-shell-inline-size: 100%; + } +} + +.profile h1 { + font-size: var(--font-size-profile-heading); + margin: 0; +} + +/* Keep skinnable profile panels consistent even when their shared component uses a different tone elsewhere. */ +.profile-card { + --panel-body-background: var(--skin-panel-background, var(--color-surface)); + --panel-border-color: var(--skin-panel-heading-background, var(--color-brand-border)); + --panel-border-width: var(--border-medium); + --panel-heading-background: var(--skin-panel-heading-background, var(--color-brand-accent)); + --panel-heading-border: var(--panel-border-color); + --panel-heading-color: var(--skin-panel-heading-text, var(--color-text-on-bright)); +} + +.profile__sidebar { + justify-items: center; + text-align: center; +} + +.profile__sidebar > :where(.profile__identity, .profile-card, .profile__url) { + justify-self: stretch; +} + +.profile__sidebar > .profile-card { + text-align: left; +} + +.profile__sidebar > :where([data-skin-part="theme-song"]) { + background: transparent; + border: 0; + box-shadow: none; + display: block; + justify-self: center; + margin-inline: auto; + outline: 0; +} + +.profile__sidebar > :where([data-skin-part="theme-song"])::-webkit-media-controls-enclosure { + background: transparent; + border-radius: 0; + box-shadow: none; +} + +.profile__sidebar > :where([data-skin-part="theme-song"])::-webkit-media-controls-panel { + background: transparent; + box-shadow: none; +} + +.profile__vibe { + display: inline-block; + width: 100%; +} + +.profile__vibe p { + margin: 0 0 var(--space-5); +} + +.profile__url { + background: var(--surface-background); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + margin: 0; + padding: var(--space-2) var(--space-4); +} + +.profile__url p { + font-size: 100%; + margin: 0; + overflow-wrap: anywhere; +} + +.profile-social-links { + margin: 0; + padding-left: 0; +} + +.profile-social-links li + li { + margin-top: var(--space-3); +} + +.profile-social-links a { + align-items: center; + display: inline-flex; + gap: var(--space-2); + overflow-wrap: anywhere; +} + +.profile__bio { + margin: 0; +} + +.profile__identity { + align-content: start; + align-items: center; + display: grid; + gap: var(--profile-identity-gap, var(--space-3)); + grid-template-columns: minmax(0, 1fr); + justify-items: center; + margin: 0; + min-width: 0; + text-align: center; + width: 100%; +} + +.profile__identity > :where(.profile__name, .profile__about) { + margin-block: 0; +} + +.profile__identity > .profile__about { + align-items: center; + display: grid; + gap: var(--space-6); + grid-template-columns: minmax(0, 1fr); + justify-items: center; + width: 100%; +} + +.profile__identity > .profile__about .profile__details p { + margin-top: 0; +} + +.profile .profile-section { + overflow-wrap: break-word; + word-break: break-word; +} + +.profile__friends .person-card { + display: inline-block; + padding: 0; + width: var(--profile-person-card-width); +} + +.profile__friends .person-card p { + color: var(--color-text-muted); + font-size: 100%; + font-weight: bold; + overflow-wrap: break-word; + text-align: center; +} + +.profile__notice { + align-items: center; + background: var(--surface-background); + border: var(--border-medium) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + color: var(--color-text); + display: flex; + justify-content: center; + margin-bottom: 0; + padding: var(--space-9) var(--space-3); + text-align: center; +} + +.profile-photo-editor { + align-items: start; + background: var(--surface-background-tint); + border: var(--border-thin) solid var(--surface-border); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-7); + grid-template-columns: auto minmax(0, 1fr); + margin: 0; + padding: var(--space-6); +} + +.profile-photo-editor__body { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.profile-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.profile-actions__cell { + align-items: center; + background: var(--surface-background-raised); + border-top: var(--border-thin) solid var(--surface-rule); + display: flex; + font-size: var(--font-size-small); + font-weight: bold; + min-height: var(--contact-cell-min-height); + padding: var(--space-4); +} + +.profile-actions__cell:nth-child(-n + 2) { + border-top: 0; +} + +.profile-actions__cell:nth-child(odd) { + border-right: var(--border-thin) solid var(--surface-rule); +} + +.profile-actions__cell:last-child:nth-child(odd) { + border-right: 0; + grid-column: 1 / -1; +} + +.profile-actions__cell form { + margin: 0; + width: 100%; +} + +:where(.profile-actions__cell) .profile-action { + --profile-action-hover-text: var(--color-link-hover); + --profile-action-text: var(--color-link); + + align-items: center; + background: transparent; + border: 0; + box-shadow: none; + color: var(--profile-action-text); + cursor: pointer; + display: inline-flex; + gap: var(--icon-label-gap); + justify-content: flex-start; + line-height: var(--line-height-body); + min-height: auto; + min-width: 0; + padding: 0; + text-align: left; + text-decoration: none; + width: 100%; +} + +:where(.profile-actions__cell) .profile-action--secondary { + --profile-action-hover-text: var(--theme-surface-link-hover); + --profile-action-text: var(--color-brand); +} + +:where(.profile-actions__cell) .profile-action--danger { + --profile-action-hover-text: var(--color-danger); + --profile-action-text: var(--color-danger-text); +} + +:where(.profile-actions__cell) .profile-action--disabled { + --profile-action-hover-text: var(--color-text-muted); + --profile-action-text: var(--color-text-muted); + + cursor: default; + opacity: var(--disabled-opacity); +} + +:where(.profile-actions__cell) .profile-action:hover { + background: transparent; + color: var(--profile-action-hover-text); + text-decoration: underline; +} + +:where(.profile-actions__cell) .profile-action--disabled:hover { + text-decoration: none; +} + +:where(.profile-actions__cell) .profile-action:active { + box-shadow: none; + transform: none; +} diff --git a/public/static/css/features/skin-guardrails.css b/public/static/css/features/skin-guardrails.css new file mode 100644 index 0000000..2cf4560 --- /dev/null +++ b/public/static/css/features/skin-guardrails.css @@ -0,0 +1,137 @@ +[data-skin-page] { + overflow-x: clip !important; +} + +[data-skin-page] :where( + .master-container, + main, + .site-nav, + .site-nav__top, + .site-nav__brand, + .site-nav__search, + .site-nav__account, + .site-nav__links, + .split-layout, + .split-layout__pane, + .profile__identity, + .profile__about, + .profile__photo, + .profile-card, + .panel, + .page-frame +) { + max-inline-size: 100% !important; + min-inline-size: 0 !important; +} + +[data-skin-page] :where(.site-nav, .site-nav__top, .site-nav__links) { + overflow-wrap: anywhere !important; +} + +[data-skin-page] .site-nav__top { + display: grid !important; +} + +[data-skin-page] .brand-link, +[data-skin-page] .brand-link__copy { + max-inline-size: 100% !important; + min-inline-size: 0 !important; +} + +[data-skin-page] .brand-link__name, +[data-skin-page] .brand-link__tagline { + overflow-wrap: anywhere !important; + white-space: normal !important; +} + +[data-skin-page] .site-nav__account .inline-links { + align-items: center !important; + display: flex !important; + flex-wrap: wrap !important; + justify-content: flex-end !important; + max-inline-size: 100% !important; + min-inline-size: 0 !important; + row-gap: var(--space-2) !important; +} + +[data-skin-page] .site-nav__account .inline-links__item { + display: inline-flex !important; + max-inline-size: 100% !important; + min-inline-size: 0 !important; + white-space: normal !important; +} + +[data-skin-page] :where(.site-nav__account, .site-nav__links) a { + max-inline-size: 100% !important; + min-inline-size: 0 !important; + overflow-wrap: anywhere !important; + white-space: normal !important; +} + +[data-skin-page] .site-nav__search form { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto !important; + inline-size: min(100%, calc(var(--nav-search-width) + 8rem)) !important; + max-inline-size: 100% !important; +} + +[data-skin-page] .site-nav__search input { + inline-size: 100% !important; + min-inline-size: 0 !important; +} + +[data-skin-page] .site-nav__search button { + min-inline-size: 0 !important; + white-space: nowrap !important; +} + +[data-skin-page] .site-nav__links { + display: flex !important; + flex-wrap: wrap !important; + justify-content: center !important; + min-inline-size: 0 !important; +} + +[data-skin-page] .site-nav__links li { + max-inline-size: 100% !important; + min-inline-size: 0 !important; +} + +@media (max-width: 48em) { + [data-skin-page] .site-nav__top { + grid-template-areas: + "brand" + "account" + "search" !important; + grid-template-columns: minmax(0, 1fr) !important; + justify-items: center !important; + text-align: center !important; + } + + [data-skin-page] .site-nav__brand, + [data-skin-page] .site-nav__account { + justify-content: center !important; + justify-self: center !important; + text-align: center !important; + } + + [data-skin-page] .site-nav__account .inline-links { + column-gap: var(--space-4) !important; + justify-content: center !important; + } + + [data-skin-page] .site-nav__account .inline-links__item + .inline-links__item::before, + [data-skin-page] .site-nav__links li + li::before { + content: none !important; + } + + [data-skin-page] .site-nav__links { + gap: var(--space-2) var(--space-4) !important; + overflow-x: clip !important; + padding-inline: calc(var(--space-5) + env(safe-area-inset-left, 0px)) calc(var(--space-5) + env(safe-area-inset-right, 0px)) !important; + } + + [data-skin-page] .site-nav__links a { + min-block-size: var(--nav-link-min-height) !important; + } +} diff --git a/public/static/css/pages/auth.css b/public/static/css/pages/auth.css new file mode 100644 index 0000000..0915bfc --- /dev/null +++ b/public/static/css/pages/auth.css @@ -0,0 +1,12 @@ +.signup-page h1 { + text-align: center; +} + +.benefits-panel .panel__body { + padding: var(--space-3); +} + +.benefits-panel__list { + margin: 0; + padding-left: var(--space-10); +} diff --git a/public/static/css/pages/blogs.css b/public/static/css/pages/blogs.css new file mode 100644 index 0000000..46ca6b1 --- /dev/null +++ b/public/static/css/pages/blogs.css @@ -0,0 +1,39 @@ +.split-layout--article { + overflow-wrap: break-word; + word-break: break-word; +} + +.blog-card__category { + font-weight: normal; +} + +.blog-card__category::before { + content: "\2022"; + margin: 0 var(--space-2); +} + +.author-details { + display: grid; + gap: var(--space-7); +} + +.author-details > :where(h1, h2, h3, h4, p) { + margin: 0; +} + +.split-layout--article .article-content { + overflow: hidden; + padding: 0 var(--space-6) 0 0; +} + +.split-layout--article .article-content p { + margin-top: 0; +} + +.split-layout--article .article-content * { + max-width: 100%; +} + +.split-layout--article .article-title { + margin: var(--article-title-offset) 0 var(--space-3); +} diff --git a/public/static/css/pages/groups.css b/public/static/css/pages/groups.css new file mode 100644 index 0000000..176df2b --- /dev/null +++ b/public/static/css/pages/groups.css @@ -0,0 +1,16 @@ +.group-owner { + margin: calc(-1 * var(--space-3)) 0 0; +} + +.group-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + font-size: var(--font-size-small); + gap: var(--space-4) var(--space-5); + margin: 0; +} + +.group-description { + margin: 0; +} diff --git a/public/static/css/pages/home.css b/public/static/css/pages/home.css new file mode 100644 index 0000000..aff3b90 --- /dev/null +++ b/public/static/css/pages/home.css @@ -0,0 +1,159 @@ +.welcome { + align-items: center; + background: var(--color-success); + border: var(--border-thin) solid var(--color-success-text); + border-radius: var(--radius-panel); + box-shadow: var(--shadow-panel); + color: var(--color-success-ink); + display: flex; + justify-content: center; + margin: 0; + padding: var(--space-3); + text-align: center; +} + +.welcome p { + margin: 0; +} + +.auth-panel .panel__body, +.summary-panel .panel__body { + text-align: center; +} + +.auth-panel :where(button, .button) { + padding: var(--space-2) var(--space-5); +} + +.auth-panel .form-stack { + gap: var(--space-5); +} + +.auth-panel .form-field { + align-items: center; + gap: var(--space-4); + grid-template-columns: max-content minmax(0, 24rem); + justify-content: center; + text-align: left; +} + +.auth-panel .form-field__label { + color: var(--color-text); + font-size: var(--font-size-small); + font-weight: normal; + text-align: right; +} + +.auth-panel .form-actions { + justify-content: center; +} + +.auth-panel p:not(.form-message), +.summary-panel p:not(.form-message) { + margin: 0; + padding: 0; +} + +.auth-panel .forgot { + display: block; + font-size: var(--font-size-utility-action); + font-weight: normal; + margin-top: var(--space-6); + text-align: right; +} + +.source-card { + background: var(--surface-background-tint); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-5); + font-weight: bold; + padding: var(--space-5); + text-align: center; +} + +.source-card p { + margin: 0; +} + +.source-card .more-details { + font-size: var(--font-size-note); + font-weight: normal; +} + +.info-grid { + display: grid; + gap: var(--space-5); + padding: var(--space-6); +} + +.info-card { + background: var(--color-brand-soft); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: inset 0 var(--border-thin) 0 var(--surface-background); + display: grid; + gap: var(--space-3); + grid-template-rows: auto 1fr auto; + padding: var(--space-3); +} + +.info-card h3, +.info-card p { + margin: 0; +} + +.info-card h3 { + color: var(--color-brand); + font-size: var(--font-size-card-heading); + margin-bottom: var(--space-3); +} + +.info-card .link { + color: var(--color-link-hover); + justify-self: end; +} + +.home-actions { + font-weight: bold; +} + +.home-actions .panel__body { + text-align: center; +} + +.home-actions .profile-pic { + margin-bottom: var(--space-5); +} + +.home-actions .more-options { + display: inline-block; + padding: 0 var(--space-3); + width: 100%; +} + +.home-stats .panel__body { + font-weight: bold; + text-align: center; +} + +.home-stats p { + margin: 0; +} + +.home-actions .profile-links .inline-links { + display: block; + margin-top: var(--space-1); +} + +.home-actions .profile-links__heading, +.home-actions .profile-url > span { + justify-content: center; + max-width: 100%; +} + +.home-actions .profile-url a { + overflow-wrap: anywhere; +} diff --git a/public/static/css/pages/messages.css b/public/static/css/pages/messages.css new file mode 100644 index 0000000..a72ac80 --- /dev/null +++ b/public/static/css/pages/messages.css @@ -0,0 +1,135 @@ +.message-conversation-panel, +.message-thread-panel { + align-content: start; +} + +.message-thread-panel-shell .panel__heading a, +.message-thread-panel-shell .panel__heading a:hover, +.message-thread-panel-shell .panel__heading a:active { + color: inherit; +} + +.message-conversation-list, +.message-thread { + display: grid; + gap: var(--space-5); + min-width: 0; +} + +.message-conversation { + align-items: start; + background: var(--surface-background); + border: var(--border-thin) solid var(--surface-border-soft); + border-radius: var(--radius-panel); + color: var(--color-text); + display: grid; + gap: var(--space-4); + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; + padding: var(--space-4); + text-decoration: none; +} + +.message-conversation:hover, +.message-conversation:active { + background: var(--surface-background-tint); + color: var(--color-text); + text-decoration: none; +} + +.message-conversation--current { + background: var(--color-brand-soft); + border-color: var(--color-brand-accent); +} + +.message-conversation--unread { + border-color: var(--color-brand-accent); + box-shadow: var(--shadow-card); +} + +.message-conversation__summary { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.message-conversation__summary small { + color: var(--color-text-muted); + overflow-wrap: anywhere; +} + +.message-conversation__name { + align-items: center; + display: flex; + gap: var(--space-3); + justify-content: space-between; + min-width: 0; +} + +.message-conversation__name > span { + font-weight: bold; + min-width: 0; + overflow-wrap: anywhere; +} + +.message-entry { + background: var(--surface-background); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-4); + min-width: 0; + overflow-wrap: break-word; + padding: var(--space-5); + scroll-margin-top: var(--space-10); + word-break: break-word; +} + +.message-entry--own { + background: var(--surface-background-raised); +} + +.message-entry__header { + align-items: start; + display: grid; + gap: var(--space-4); + grid-template-columns: auto minmax(0, 1fr); +} + +.message-entry__meta p { + margin: 0; +} + +.message-entry__meta small { + color: var(--color-text-muted); +} + +.message-entry__body > :first-child { + margin-top: 0; +} + +.message-entry__body > :last-child { + margin-bottom: 0; +} + +.message-entry__actions { + border-top: var(--border-thin) solid var(--surface-rule); + padding-top: var(--space-3); +} + +.message-reply { + border-top: var(--border-thin) solid var(--surface-rule); + display: grid; + gap: var(--space-5); + padding-top: var(--space-5); +} + +.message-reply h2 { + font-size: var(--font-size-panel-heading); + margin: 0; +} + +.message-reply__form { + margin: 0; +} diff --git a/public/static/css/pages/notifications.css b/public/static/css/pages/notifications.css new file mode 100644 index 0000000..6334b02 --- /dev/null +++ b/public/static/css/pages/notifications.css @@ -0,0 +1,27 @@ +.notification-list { + display: grid; + gap: var(--space-7); +} + +.notification-card { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-7); + margin: 0; + padding: var(--space-5) var(--space-6); +} + +.notification-card--unread { + border-color: var(--color-brand-accent); + box-shadow: var(--surface-shadow), inset var(--border-medium) 0 0 var(--color-brand-accent); +} + +.notification-card__meta { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: var(--space-5); +} diff --git a/public/static/css/pages/staff.css b/public/static/css/pages/staff.css new file mode 100644 index 0000000..b839b9f --- /dev/null +++ b/public/static/css/pages/staff.css @@ -0,0 +1,270 @@ +.report { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + box-shadow: var(--surface-shadow); + display: grid; + gap: var(--space-5); + margin: 0; + padding: var(--space-5); +} + +.report > * { + margin-block: 0; +} + +.staff-subnav { + --icon-size: 1em; + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + line-height: 1.4; +} + +.staff-subnav__item { + align-items: center; + display: inline-flex; + white-space: nowrap; +} + +.staff-subnav__item::before { + content: "["; +} + +.staff-subnav__item::after { + content: "]"; +} + +.icon-field { + align-items: center; + display: flex; + gap: var(--space-3); +} + +.icon-field .icon { + color: var(--color-brand); + flex: 0 0 auto; + margin: 0; +} + +.icon-field .icon svg { + height: 1.5em; + width: 1.5em; +} + +.icon-field input { + min-width: 0; +} + +.report__summary { + background: var(--color-row-alt); + border: var(--border-thin) solid var(--surface-border-soft); + border-radius: var(--radius-panel); + margin: 0; + padding: var(--space-3); +} + +.report__resolution { + border-top: var(--border-thin) solid var(--surface-rule); + color: var(--color-text-muted); + padding-top: var(--space-4); +} + +.audit-table-wrap { + max-width: 100%; + overflow-x: auto; +} + +.audit-table { + min-width: 48rem; + table-layout: fixed; +} + +.audit-table__time-column { + width: 18%; +} + +.audit-table__actor-column { + width: 18%; +} + +.audit-table__action-column { + width: 18%; +} + +.audit-table__subject-column { + width: 46%; +} + +.audit-table td { + text-align: left; +} + +.audit-action-name, +.audit-subject-label { + display: block; + font-weight: bold; +} + +.audit-action-cell small, +.audit-subject-cell small, +.audit-table td > small { + color: var(--color-text-muted); + display: block; + font-size: var(--font-size-small); + margin-top: var(--space-1); + overflow-wrap: anywhere; +} + +.audit-subject-label small { + display: inline; + margin-left: var(--space-2); +} + +.audit-subject-summary { + background: var(--color-row-alt); + border: var(--border-thin) solid var(--surface-border-soft); + border-radius: var(--radius-panel); + display: block; + margin-top: var(--space-2); + padding: var(--space-2); +} + +.audit-note { + margin-top: var(--space-2); +} + +.audit-note summary { + cursor: pointer; +} + +.moderation-form { + border-top: var(--border-thin) solid var(--surface-rule); + margin-top: 0; + padding-top: var(--space-5); +} + +.moderation-form button { + margin: var(--space-2) var(--space-2) 0 0; +} + +.automod-section { + border-bottom: var(--border-thin) solid var(--surface-rule); + margin: 0; + padding: 0 0 var(--space-7); + scroll-margin-top: var(--space-10); +} + +.automod-section h4 { + margin-top: 0; +} + +.automod-rule-list { + display: grid; + gap: var(--list-stack-gap); +} + +.automod-rule { + background: var(--surface-background-raised); + border: var(--border-thin) solid var(--surface-border); + border-radius: var(--radius-panel); + margin: 0; + padding: var(--space-4); + scroll-margin-top: var(--space-10); +} + +.automod-rule__summary { + cursor: pointer; + overflow-wrap: anywhere; +} + +.automod-rule__meta { + color: var(--color-text-muted); + margin-left: var(--space-4); +} + +.automod-rule .form-stack { + margin-top: var(--space-5); +} + +.rate-limit-panel > .panel__heading { + align-items: center; +} + +.rate-limit-table-wrap { + max-width: 100%; + overflow-x: auto; +} + +.rate-limit-table { + min-width: 32rem; + table-layout: fixed; +} + +.rate-limit-table__action-column { + width: 54%; +} + +.rate-limit-table__limit-column { + width: 20%; +} + +.rate-limit-table__window-column { + width: 26%; +} + +.rate-limit-table th, +.rate-limit-table td { + vertical-align: middle; +} + +.rate-limit-action-cell { + text-align: left; +} + +.rate-limit-action-name { + display: block; + font-weight: bold; +} + +.rate-limit-action-cell small { + color: var(--color-text-muted); + overflow-wrap: anywhere; +} + +.rate-limit-control { + align-items: center; + display: inline-flex; + gap: var(--space-2); + justify-content: center; + white-space: nowrap; +} + +.rate-limit-control input { + box-sizing: border-box; + max-width: 5rem; + min-width: 0; + text-align: right; + width: 100%; +} + +.rate-limit-note { + color: var(--color-text-muted); + font-size: var(--font-size-small); + text-align: center; +} + +.rate-limit-actions, +.rate-limit-reset-form { + display: flex; + justify-content: center; +} + +.rate-limit-reset-form { + margin-top: var(--space-4); +} + +.rate-limit-reset-form button { + width: auto; +} diff --git a/public/static/css/responsive/responsive.css b/public/static/css/responsive/responsive.css new file mode 100644 index 0000000..0d23c4b --- /dev/null +++ b/public/static/css/responsive/responsive.css @@ -0,0 +1,214 @@ +@media (min-width: 48em) { + .split-layout { + display: grid; + width: 100%; + } + + .split-layout--landing { + grid-template-columns: minmax(0, var(--column-wide)) minmax(var(--column-aside-min), 1fr); + position: relative; + } + + .split-layout--dashboard, + .split-layout--profile { + grid-template-columns: minmax(var(--profile-sidebar-min), var(--column-profile)) minmax(0, 1fr); + } + + .split-layout--article, + .split-layout--editor { + grid-template-columns: minmax(var(--column-aside-min), var(--column-compact)) minmax(0, 1fr); + } + + .split-layout--messages { + grid-template-columns: minmax(14rem, 32%) minmax(0, 1fr); + } + + .info-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .split-layout--landing > .split-layout__main { + border-right: 0; + box-shadow: none; + } + + .split-layout--landing::after { + background: linear-gradient( + to right, + var(--color-brand-soft) 0 var(--border-thin), + var(--color-surface) var(--border-thin) var(--border-medium), + var(--color-panel-accent-soft) var(--border-medium) calc(var(--border-medium) + var(--border-thin)) + ); + bottom: var(--space-6); + content: ""; + left: var(--column-wide); + pointer-events: none; + position: absolute; + top: var(--space-6); + width: calc(var(--border-medium) + var(--border-thin)); + } + + .split-layout--article .content-meta-links a { + display: block; + margin-bottom: var(--space-3); + } +} + +@media (max-width: 48em), (any-pointer: coarse) { + html, + body { + -ms-overflow-style: none; + height: 100%; + min-height: 100%; + overflow: hidden; + scrollbar-width: none; + touch-action: pan-x pan-y; + width: 100%; + } + + html::-webkit-scrollbar, + body::-webkit-scrollbar, + .master-container::-webkit-scrollbar { + display: none; + height: 0; + width: 0; + } + + .master-container { + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + height: 100vh; + height: 100dvh; + min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-width: none; + touch-action: pan-x pan-y; + } + + :where( + input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="reset"]):not([type="submit"]):not([type="image"]), + textarea, + select + ) { + font-size: 16px; + font-size: max(16px, 1em); + } +} + +@media (max-width: 48em) { + .site-nav__top { + align-items: center; + display: grid; + gap: var(--space-5); + grid-template-areas: + "brand" + "account" + "search"; + grid-template-columns: minmax(0, 1fr); + justify-items: center; + text-align: center; + } + + .site-nav__brand { + grid-area: brand; + justify-content: center; + justify-self: center; + min-width: 0; + } + + .site-nav__account { + grid-area: account; + justify-self: center; + min-width: 0; + position: static; + text-align: center; + } + + .site-nav__search { + grid-area: search; + grid-column: 1 / -1; + width: 100%; + } + + .site-nav__search form { + display: inline-flex; + flex-wrap: nowrap; + gap: var(--space-2); + justify-content: center; + max-width: 100%; + min-width: 0; + } + + .site-nav__search input { + flex: 1 1 var(--nav-search-width); + min-width: 0; + width: var(--nav-search-width); + } + + .site-nav__search button { + flex: 0 0 auto; + } + + .profile-photo-editor { + grid-template-columns: minmax(0, 1fr); + } + + .site-nav__links { + overflow-wrap: anywhere; + padding-left: var(--space-5); + padding-right: var(--space-5); + } + + .listing-table { + display: block; + overflow-x: auto; + } + + .discussion-entry { + grid-template-columns: 1fr; + } + + .discussion-entry--reply { + margin-left: 0; + } + + .discussion-entry__author { + align-items: center; + flex-direction: row; + justify-content: flex-start; + text-align: left; + } + +} + +@media (max-width: 30em) { + .site-nav__top { + --site-nav-top-padding-inline: var(--space-5); + } + + .profile__identity > .profile__about { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 22em) { + .profile-actions { + grid-template-columns: minmax(0, 1fr); + } + + .profile-actions__cell:nth-child(n) { + border-right: 0; + border-top: var(--border-thin) solid var(--surface-rule); + } + + .profile-actions__cell:first-child { + border-top: 0; + } + + .profile-actions__cell:last-child:nth-child(odd) { + grid-column: auto; + } +} diff --git a/public/static/css/shell/shell.css b/public/static/css/shell/shell.css new file mode 100644 index 0000000..c14411e --- /dev/null +++ b/public/static/css/shell/shell.css @@ -0,0 +1,284 @@ +.site-nav { + color: var(--color-text-on-bright); + font-size: var(--font-size-nav); +} + +.site-nav__top { + --site-nav-top-padding-inline: var(--space-6); + + align-items: center; + background: var(--color-brand-header); + column-gap: var(--space-6); + display: grid; + grid-template-areas: "brand search account"; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + padding: + calc(var(--space-9) + env(safe-area-inset-top, 0px)) + calc(var(--site-nav-top-padding-inline) + env(safe-area-inset-right, 0px)) + var(--space-8) + calc(var(--site-nav-top-padding-inline) + env(safe-area-inset-left, 0px)); + position: relative; +} + +.site-nav__top a { + color: inherit; +} + +.site-nav__brand, +.site-nav__account { + min-width: var(--nav-side-min); +} + +.site-nav__brand { + align-items: center; + display: flex; + grid-area: brand; + justify-self: start; +} + +.site-nav__search { + grid-area: search; + justify-self: center; + min-width: 0; + text-align: center; +} + +.site-nav__search form { + align-items: center; + display: inline-flex; + gap: var(--space-2); + justify-content: center; + margin: 0; +} + +.site-nav__search input { + width: var(--nav-search-width); +} + +.site-nav__account { + grid-area: account; + justify-self: end; + text-align: right; +} + +.site-nav__icon-link { + align-items: center; + display: inline-flex; + height: 1em; + justify-content: center; + line-height: 1; + vertical-align: -0.1em; + width: 1em; +} + +.theme-toggle__icon { + align-items: center; + justify-content: center; + line-height: 1; +} + +.theme-toggle__icon--dark { + display: var(--theme-toggle-dark-display); +} + +.theme-toggle__icon--light { + display: var(--theme-toggle-light-display); +} + +.site-nav__icon-link .icon { + align-items: center; + display: inline-flex; + height: 1em; + justify-content: center; + margin: 0; + vertical-align: 0; + width: 1em; +} + +.site-nav__icon-link .icon svg { + height: 0.95em; + width: 0.95em; +} + +.site-nav__notification-link { + flex: 0 0 auto; + gap: 0; + min-height: 1em; + min-width: 1em; + overflow: visible; + position: relative; + width: auto; +} + +.site-nav__notification-badge { + --count-badge-font-size: 0.72em; + --count-badge-min-size: 1.22em; + --count-badge-padding-x: var(--space-1); + + align-self: flex-start; + pointer-events: none; + position: relative; + transform: translate(-30%, -45%); + z-index: 1; +} + +.site-nav__top .site-nav__notification-link:hover, +.site-nav__top .site-nav__notification-link:active { + background: transparent; + color: inherit; +} + +.site-nav__notification-link:hover .icon, +.site-nav__notification-link:active .icon { + background: var(--color-brand-soft); + color: var(--color-brand); +} + +.brand-link { + align-items: center; + display: inline-flex; + font-size: var(--font-size-profile-heading); + font-weight: bold; + gap: var(--space-5); + line-height: 1; + vertical-align: middle; +} + +.brand-link__copy { + display: inline-flex; + flex-direction: column; + gap: var(--space-1); +} + +.brand-link__name, +.brand-link__tagline { + display: block; +} + +.brand-link__tagline { + font-size: max(0.52em, 12px); + font-weight: normal; + line-height: 1.15; + opacity: 0.88; + white-space: nowrap; +} + +.brand-link .icon { + margin: 0; + vertical-align: middle; +} + +.brand-link .icon svg { + height: var(--brand-icon-size); + width: var(--brand-icon-size); +} + +.site-nav__account, +.site-nav__links { + --link-separator-color: var(--color-text-on-bright); +} + +.site-nav__links { + background: var(--color-brand-nav); + border-block: var(--border-thin) solid var(--color-brand-header); + display: flex; + flex-wrap: wrap; + font-size: var(--font-size-nav-link); + justify-content: center; + padding: var(--space-nav-links-y) var(--space-nav-links-x); +} + +.site-nav__links a { + color: var(--color-text-on-bright); + display: inline-block; + min-height: var(--nav-link-min-height); + padding: var(--space-2) var(--space-3); +} + +.site-nav__link-label { + display: inline-block; + line-height: 1; + position: relative; +} + +.site-nav__link-badge { + --count-badge-font-size: 0.7em; + --count-badge-min-size: 1.2em; + --count-badge-padding-x: var(--space-1); + + inset-block-start: 0; + inset-inline-end: 0; + pointer-events: none; + position: absolute; + transform: translate(70%, -45%); + z-index: 1; +} + +.site-nav__top a:hover, +.site-nav__links a:hover { + background: var(--color-brand-soft); + color: var(--color-brand); + text-decoration: none; +} + +.site-footer { + background: var(--color-surface-tint); + border-top: var(--border-thin) solid var(--color-container-border); + box-shadow: inset 0 var(--border-thin) 0 var(--color-surface); + font-size: var(--font-size-footer); + margin: var(--space-6) 0 0; + padding: var(--space-6) var(--space-3); + text-align: center; +} + +.site-footer p { + font-size: var(--font-size-note); + margin: var(--space-6) 0 var(--space-3); +} + +.site-footer__links { + align-items: center; + display: flex; + flex-wrap: wrap; + font-size: var(--font-size-small); + justify-content: center; + row-gap: var(--space-2); +} + +.site-footer__social { + --icon-size: 1.4em; + + margin: var(--space-4) 0 var(--space-3); +} + +.site-footer__social ul { + align-items: center; + display: flex; + gap: var(--space-6); + justify-content: center; + margin: 0; + padding: 0; + list-style: none; +} + +.site-footer__social a { + align-items: center; + color: var(--color-text-muted); + display: inline-flex; + line-height: 1; +} + +.site-footer__social a:hover, +.site-footer__social a:focus { + color: var(--color-link); +} + +.site-footer__social .icon { + vertical-align: 0; +} + +.site-footer .copyright { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + margin-top: var(--space-3); +} diff --git a/public/static/css/style.css b/public/static/css/style.css new file mode 100644 index 0000000..acf10fd --- /dev/null +++ b/public/static/css/style.css @@ -0,0 +1,32 @@ +@layer tokens, base, layout, shell, components, features, pages, skin-guardrails, profile-skin, responsive; + +@import url("./core/tokens.css") layer(tokens); +@import url("./core/base.css") layer(base); +@import url("./core/layout.css") layer(layout); +@import url("./core/flow.css") layer(layout); +@import url("./shell/shell.css") layer(shell); +@import url("./components/avatars.css") layer(components); +@import url("./components/panels.css") layer(components); +@import url("./components/content.css") layer(components); +@import url("./components/metadata.css") layer(components); +@import url("./components/links.css") layer(components); +@import url("./components/forms.css") layer(components); +@import url("./components/groups.css") layer(components); +@import url("./components/people.css") layer(components); +@import url("./components/tables.css") layer(components); +@import url("./components/actions.css") layer(components); +@import url("./components/badges.css") layer(components); +@import url("./components/pagination.css") layer(components); +@import url("./components/discussion.css") layer(components); +@import url("./components/posts.css") layer(components); +@import url("./components/formatting-help.css") layer(components); +@import url("./features/profile.css") layer(features); +@import url("./features/skin-guardrails.css") layer(skin-guardrails); +@import url("./pages/auth.css") layer(pages); +@import url("./pages/home.css") layer(pages); +@import url("./pages/groups.css") layer(pages); +@import url("./pages/blogs.css") layer(pages); +@import url("./pages/messages.css") layer(pages); +@import url("./pages/notifications.css") layer(pages); +@import url("./pages/staff.css") layer(pages); +@import url("./responsive/responsive.css") layer(responsive); diff --git a/public/static/css/themes/dark.css b/public/static/css/themes/dark.css new file mode 100644 index 0000000..677212a --- /dev/null +++ b/public/static/css/themes/dark.css @@ -0,0 +1,17 @@ +@layer tokens { + :root:not([data-theme-lock="light"]) { + color-scheme: dark; + + --theme-backdrop: #17131d; + --theme-page: var(--theme-backdrop); + --theme-surface: #211b28; + --theme-page-text: #f3eef9; + --theme-link: #a78bfa; + --theme-muted: #8b8197; + --theme-danger: #f87171; + --theme-success-text: var(--theme-success); + + --theme-toggle-dark-display: none; + --theme-toggle-light-display: inline-flex; + } +} diff --git a/public/static/icons/bliish.ico b/public/static/icons/bliish.ico new file mode 100644 index 0000000000000000000000000000000000000000..c8a1dd9adfe26ceb3031c5581defebc12e0477ee GIT binary patch literal 5245 zcmaJ@WmFVgyB)d&X6R;y0g*0YfI*Nh=`N+ak&y02K)NLb1tg?FYDkfxWu$XJT0t5v z@Av+>KfZf^oVC|JXRqgZ&RTn|{Qv+M04x9y2zWp+pa>HH5CH%HVDP_O4Hp2IeYoT2 z|CgH(0RSun06?6kx*{j@PFmM5KO^lxRYwajhz!y4+8#?xybHq#+G!jkC3^vKu=w8m1xL#foJXnf z#ok0=!n(a?2_6Vj9m{%JtcLEhSCY?lqhxC;K5Sod@h@tnKB_mzXS@jV8^3&pY>~x> zSR5cJMHvR)R|!55u=YG*Z?F_?EF0j|(EH`VS5ySS-q z=UB5TlPtMcK`JFJg+pBT5}O-NhH~fLBn3Z2%3-L|U^6m=7cWLpA)eLhnh>kNuj?0h z#*Q-lSzV!RfdIAgH21w9Y#N?t5(r)oN3k!NY^?cPCN!Q0#Mp|GmUbop?A7UxqdNU) zwwNnp5=4bjbeFYWpMFC)T7%^^IAhq{g-zYdFOZ4!scOJiTUta3NwG3xW?iM-#0LJP z%t8_%SEGAiaV9>!EeU&!9V_G#0@SgPs-=)zC}-a9A`o{!OQ+6CKDEpyP&JwdnREUs z`8v-Q9Y{48c+bln=sUF2(;rQe{*kVpzlC$_Y{i3yh~`&#B}^cWWf)}%Xbi>N(mYwOD;W;w#mD#DDCy%RYUpSr`t4#{rI7EN$~?n< zY5ndzXc>8)qip-Gxbnxxo$S71aT7g5K3ap(gvXE0RZsn>`D^{-6pVrt^fUuoQEyrF zY~BgBYgLqQz2kTqze1X5!aKdo^)6k@t1p2(ri31GI>vZM;aUXMDRRn>X#3jIYrP1X z7GfKUv5gU>UUv3{YkcVNwk5o`ZEVZ2ku%H05T+H^Et<=%_0IX?P%zUC%&xwYC{X`m z;Hql2{xPzNR;N;IphEn6EWHD+sn{ih4SV5wpp2ACGKWVTJbJIne_C>PQKs@+wv5eG zW$4HCx92Z6=%m*j8!)moUdS*kMA48km2^$jBZY_@Ai|Xr6}_|8xqhz2rt6OWRSm5k zW_l$cR$k_EEvgUFo`wX*Tgm*zla_w&6n1GUDM;F$0c=KIW?>+bH*%`ggXathVdT6M zB;$aWDS{NEObm=q{CAsht)qoYPTx~)#Sq&fyNMs?i8GhV6AV4y>kj5+_!%jN-s4B5bIT-@Ji$s0S7MY~gYF#pt1vug5W#hr7iG1eBcD1Z z7-o5_h9)evZ(PoLP)~%AT|#h&@s-YZdPwP{L)ZgC8Pu`HuSg(Pb})5UozOj0OwzQW<0^#*I1c@?nKO zYyxG90^#(Rq?0}-1Y>pQHaW1Aoj~NKvd0^_Qys-Q7^HVsG8MX)=P|!a)xN;gxcR+l zY4vV7a+8?Q9k$%BmlHlajS>va{`@&?B-XcG@fa4iUR~Q8CJc*1)Yi(6{p6HzWV9U7 z>E{G?G~?DgIrbWvCAB7PRTsty@RWSz?sZmR{D!R70eu?RD%uXTc z%#ZkX@#{9A!!N_1!y$nN6%DxjbQN{zX^AxUnc|c!yN0SIA4}{7G`YYC|HQLL$=;Lh zSHpNxD7;lyGonxE!!wbG_8@C^$W@N>=!$;qa}$D8fQi5j<1iM#uvoFIlNGf>(Ob~@ zbg2r+oJ;0?P40pT|2qwO^gYmf_!`u%;@D2$75q5wcN3^* zMZp;tbmJWPM9M%)@viI@vBebSSSL6z2yogYg)w9#9)M-LWXX@3;oV#G=q_i;R*bE5 zLsW9;KS!0yI0Z4UKeNU+%Sk4el((<+k6n`#R>&aVNg)j{(u~@1`vbD)%NY{;roLxN zd=_(tdR$b_G8R+RMFaU{9ebf2UB^cV+<6j()Q0$ku*mP&3Cabt$!@k|%2jj79TU>t z>?8hMyS>tj9- z37_CCW1Gp{!L~~QtZT_QloTVaX@t{eI{#i4_Ko7)MPH)n+){Fx{bi;ECD)BjPwT24iN%~D_sbQ+tI)&px&{wOEaQ5kof2CJJ#RA?@6LYPkzmO_z_v`!ezJ>h1czApE(-i zy6HHs~n@M*wdc~tiR}!{QpXEe0@Pxx2g7Vz&Z1QGX$b`=BZ(l zjG9On6VQ~+42)%-xmx~)FwYDgo8+J)kjD;l>K&){PCT>QFp_-kyk6CJ+i8e;j@BEC!w$hv@)I%ahWZd z&Kwfou*2(Llek-c{;7@7|82hAo&k)Wj2+iujD}WYX}Ter<82`KM-8nF&wRQjyNZg( z`tohBjl+o8@ihH639x%xQZ@r{Ovl;$xA2;ZSwE7aHBP6!X6sdq`{!DGsDK9RrVO(K zcH>ODhm~*jHl{d)c?Vyp%_>t`vny!Q@+c~AdX-%?^{jAGzRYA_Z9Aip;7RUxGa(}j zqSIoyOe}<=l^IP|5*kBW$Q>nD$r0C9@NQQC6I~OnUFRJpp2POke~r5h{oB z;_wi9DzW$BhfMvWySca&?2zP!;b8y58MP1H&QugJ-sk*m#kW=&sgCv=ix!llP>Lrc zL17f?^qjvRQn6ioHePpy{t4lMEUMTdA6{M7G(A$84=J~}Eq!q!EjHReTb&;DNt3EV zPI-r)F#HAOGiT>py@Zk%K3Gg_RX1%um z^SG%L5CtA7#5aU=+?_Yvg^jsVX8fxw1HXQ1z6w-sal z4yP6ce|Y$TZUUFQq(Co1EO|qbBZ3t1W<_OM2R$X-%VM=qidM#eqvPUJa(+)=1hz%o){% z@^B|ro;;bAHNTm?U4yXw`sCW@X29*N))rf5FLRSsptOLBdC>vMl^0YN@yH%cZWS6? zcm)oM3D5Y#*ss(`Co}pN1K}1BO+G$bjm2<=F79f>`Z>3=LsQ0)F@LKi!6Nd7#dS{y zSftA(_I+kseq8V*QzNzW0EbI?;VR`z6E#6564Y@CDq*y#wsiUKE0CtgS{?l%2RKRP z+q?7|JIONMK zys9T0Ti>eTrNlI1^M~c|#n|Ar=$}hjeM_m3P{Raf566vj(XV&(a;S{8^OqV-KK(&9 zxNmDalG5mp*1imoKF#``&?+?P)U7bq9#EZc`XelDdu6@#l99n^I=Ua8&_u|w$(jDg zI2B|{`qCbYmpUY=zJBFK*lTXwRR_k<>eFoJY7w8FnEOSX>5%!JMQfPxub)OvWs5||hO8B0KQ~{ItXhIY zJBoF=T%*Za>EbJ%0gR*qukqQmX*r3UCa*h-&h{_nMKNL4y|d4i8W%bC)e!|ABEX&` zLdOV+dlpgJmdzX%vGbO&_sNhf3UAiqhh*-KpbV6_ToRPT!U> z-;h&IXBrG=h;FA7e{N}T2ze*;jF})7zVRM8ElRJ%|7;jw*~&{L?b+_A4GDhD_BYoS z3N2yi*SR@6M0+32Sur$ZhW_#4Jy5;USdqt!!3h+$f=hKxE5AzIg@!j4(*%F>N}(`~ z&-@7Au@6YJMKL0d_)5;r5(kdWGGl;51c(pTt+U6^3MF@BeeI+USANNPTX3m+5dtr| zCH!(Wd$fuqawBv6Vl6trw;-$1tf|dPzQHkIFG>mXi0#esy&DFm{`NE%xUn2&eVBMiS$ zR#YHyO=D_PXv^T5DDw3RYKQu<6j!c*zPJZy%zr86{D2yN(1#DWb`*Jw**o7ZhL<&U zZVi7;{+nT&=)|{A1@`jlH_z6Un6%q|e%7*;)T`KTEclsV`;T#AaPlFQ`QcIV&ux*82Mc2|qdVb1V@I6=~a_dB@nPQ|Ahw9R^QsI}29 j>$ByGvlg#Z|8Jee+_$S@Za12@73J(ku%r{k^ve3pjsV( zB8W)IspuG)kQ`jR{C9-KBxU85RW)@COzv4&+u1*KedO);)7yw+sCkOx_MuK>Qe`VlD@KzKJ z0C3MD2!IBZW^8lkM+TD;$5%bPPHW|`|FB1)*DO5O!{4;Ju;1eMxWVrN0N%Dlqx{E| z>fTB$_Fu|nFc!OpJ=?mt8HervG0s1#|BNHwg9hMKb@?6@@Ea;{@E_3mNaH&EHQkB? zi~XDKb^Z2yVj6p4`tuM(FgRz30s#M&v;R*1VEF)hS>&h!KqTQ> zuWdofMfX((005VcP#_l#03GNb>>v*j6C67=@5!9rI?|s-ZW2DVATFtv{-;kBZ2aWJ z2kS}=Fp&Y!BIwMfNR%QPE~o*EV`L{yn6d*fLj3PnS!KUAFLe8)MiLT2@vCfea35+e z+i5h}X;|g2g`dJ)8|5M}h}fBM|As%mzG;s-!h1|{6J`|8U3UWZ?Y$Dh=0>W%(@VPH zbNME0Jz8GOPz`KJccne^62ht?BV?Ba)qo?pIe`M+Ig zv+rlt#e(hXbud#-abd!M(3STmla5baHxIE-<-0W7zWTjrU=w6>!uI^2BTW?n$nX^0 z;208s<&UNU8i4xOinR4Hqq3p#(Q6p~v%ZJXCsjpsz4W&Iq|!R9CLg)*_>cal>z3ZN{Fn%asrA=FTVLwxT-BoqIGGe?0Zg$?L z{KwxV1B}>yMT64r+^-mi!RWX*WOle2dSoP;<{u>V`ffLq{cun-chgklT}El4?+AIn zOuSH7q>Rc?nc#O&nnv{aCGpl)8W|;wh)=L>esVBV85(HZ<=!zYDNC<)mE#p_h^yQs zg)cEPXs9sr2DZf9D*^!Y3Za4`*>p^Me>igSws5kRU7Sz3(G4eT0Jvsa28V%;=pd&r zq@$|50D)zM9c8UznbfuCZE7j4{YfPIMrH1PY zp>2No`pNd4b1UD-G*>e=z=htb=O6Cqkz~3{WYv72yWE-y9kb=CP$yq({H*dvaZi