├── .claude └── settings.local.json ├── .devcontainer ├── codespaces │ └── devcontainer.json └── devcontainer.json ├── .dockerignore ├── .env.ci ├── .env.sample ├── .github ├── copilot-instructions.md └── workflows │ └── main.yml ├── .gitignore ├── .graphqlrc.yml ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tailwind.json ├── .vsls.json ├── .zed └── settings.json ├── CLAUDE.md ├── CODE_OF_CONDUCT.en.md ├── CODE_OF_CONDUCT.ja.md ├── CODE_OF_CONDUCT.ko.md ├── CODE_OF_CONDUCT.md ├── CODE_OF_CONDUCT.zh-CN.md ├── CODE_OF_CONDUCT.zh-TW.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── README.md ├── ai ├── chunk.test.ts ├── chunk.ts ├── deno.json ├── language.ts ├── mod.ts ├── prompts │ ├── summary │ │ ├── en.md │ │ ├── ja.md │ │ ├── ko.md │ │ ├── zh-CN.md │ │ └── zh-TW.md │ └── translate │ │ ├── en.md │ │ ├── ja.md │ │ ├── ko.md │ │ ├── zh-CN.md │ │ └── zh-TW.md ├── summary.ts └── translate.ts ├── deno.json ├── deno.lock ├── docker-compose.yml ├── drizzle.config.ts ├── drizzle ├── 0000_init.sql ├── 0001_account_key.sql ├── 0002_account.username_changed.sql ├── 0003_account_link.icon.sql ├── 0004_actor__and__instance.sql ├── 0005_actor.published.sql ├── 0006_actor.published__nulable.sql ├── 0007_actor__and__instance__checks.sql ├── 0008_article_draft.sql ├── 0009_article_source.sql ├── 0010_article_source.language.sql ├── 0011_article_source__check.sql ├── 0012_article_source__check.sql ├── 0013_following.sql ├── 0014_post.sql ├── 0015_post.name.sql ├── 0016_post_article_source_id_check.sql ├── 0017_mention.sql ├── 0018_get-rid-of-account.deleted.sql ├── 0019_note_source.sql ├── 0020_post_note_source_id_check.sql ├── 0021_allowed_email.sql ├── 0022_post_visibility.sql ├── 0023_medium.sql ├── 0024_unique_post.actor_id_shared_post_id.sql ├── 0025_account.avatar_key.sql ├── 0026_rename-medium-to-post_medium.sql ├── 0027_rename-medium_type-to-post_medium_type.sql ├── 0028_note_medium.sql ├── 0029_account.old_username.sql ├── 0030_account.moderator.sql ├── 0031_rename-account.old_username.sql ├── 0032_og_image_key.sql ├── 0033_on-delete-cascade.sql ├── 0034_delete-orphan-note-sources.sql ├── 0035_account.locales.sql ├── 0036_post.summary.sql ├── 0037_post_link.sql ├── 0038_post_link.site_name.sql ├── 0039_post_link.image_alt.sql ├── 0040_post_link.author.sql ├── 0041_post_link_url_check.sql ├── 0042_account.left_invitations.sql ├── 0043_account.inviter_id.sql ├── 0044_post.quoted_post_id.sql ├── 0045_actor.handle.sql ├── 0046_not-null-actor.handle.sql ├── 0047_account.hide_from_invitation_tree.sql ├── 0048_indices.sql ├── 0049_timeline_item.sql ├── 0050_fill timeline_item.sql ├── 0051_timeline_item.original_author_id.sql ├── 0052_timeline_item.appended.sql ├── 0053_post.quotes_count.sql ├── 0054_notifications.sql ├── 0055_populate_notifications.sql ├── 0056_idx_notification_account_id_created.sql ├── 0057_account.notification_read.sql ├── 0058_account.hide_foreign_languages.sql ├── 0059_reaction.sql ├── 0060_add-react-to-notification_type.sql ├── 0061_add-react-to-notification_type.sql ├── 0062_post.reactions_count.sql ├── 0063_post.reactions_count.sql ├── 0064_blocking.sql ├── 0065_normalize-tags.sql ├── 0066_actor.tags.sql ├── 0067_passkey.sql ├── 0068_passkey.name.sql ├── 0069_passkey.created.sql ├── 0070_passkey.last_used.sql ├── 0071_normalize_invalid_timestamps.sql ├── 0072_post_medium.thumbnail_key.sql ├── 0073_add-video-quicktime-to-post_medium_type.sql ├── 0074_poll.sql ├── 0075_unique-constraint-poll_option.title.sql ├── 0076_pin.sql ├── 0077_article_content.sql ├── 0078_article_content_original_language_check.sql ├── 0079_article_content.summary.sql ├── 0080_article_content.being_translated.sql ├── 0081_account.prefer_ai_summary.sql ├── 0082_invitation_link.sql ├── 0083_index-lower-email.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ ├── 0010_snapshot.json │ ├── 0011_snapshot.json │ ├── 0012_snapshot.json │ ├── 0013_snapshot.json │ ├── 0014_snapshot.json │ ├── 0015_snapshot.json │ ├── 0016_snapshot.json │ ├── 0017_snapshot.json │ ├── 0018_snapshot.json │ ├── 0019_snapshot.json │ ├── 0020_snapshot.json │ ├── 0021_snapshot.json │ ├── 0022_snapshot.json │ ├── 0023_snapshot.json │ ├── 0024_snapshot.json │ ├── 0025_snapshot.json │ ├── 0026_snapshot.json │ ├── 0027_snapshot.json │ ├── 0028_snapshot.json │ ├── 0029_snapshot.json │ ├── 0030_snapshot.json │ ├── 0031_snapshot.json │ ├── 0032_snapshot.json │ ├── 0033_snapshot.json │ ├── 0034_snapshot.json │ ├── 0035_snapshot.json │ ├── 0036_snapshot.json │ ├── 0037_snapshot.json │ ├── 0038_snapshot.json │ ├── 0039_snapshot.json │ ├── 0040_snapshot.json │ ├── 0041_snapshot.json │ ├── 0042_snapshot.json │ ├── 0043_snapshot.json │ ├── 0044_snapshot.json │ ├── 0045_snapshot.json │ ├── 0046_snapshot.json │ ├── 0047_snapshot.json │ ├── 0048_snapshot.json │ ├── 0049_snapshot.json │ ├── 0050_snapshot.json │ ├── 0051_snapshot.json │ ├── 0052_snapshot.json │ ├── 0053_snapshot.json │ ├── 0054_snapshot.json │ ├── 0055_snapshot.json │ ├── 0056_snapshot.json │ ├── 0057_snapshot.json │ ├── 0058_snapshot.json │ ├── 0059_snapshot.json │ ├── 0060_snapshot.json │ ├── 0061_snapshot.json │ ├── 0062_snapshot.json │ ├── 0063_snapshot.json │ ├── 0064_snapshot.json │ ├── 0065_snapshot.json │ ├── 0066_snapshot.json │ ├── 0067_snapshot.json │ ├── 0068_snapshot.json │ ├── 0069_snapshot.json │ ├── 0070_snapshot.json │ ├── 0071_snapshot.json │ ├── 0072_snapshot.json │ ├── 0073_snapshot.json │ ├── 0074_snapshot.json │ ├── 0075_snapshot.json │ ├── 0076_snapshot.json │ ├── 0077_snapshot.json │ ├── 0078_snapshot.json │ ├── 0079_snapshot.json │ ├── 0080_snapshot.json │ ├── 0081_snapshot.json │ ├── 0082_snapshot.json │ ├── 0083_snapshot.json │ └── _journal.json ├── federation ├── actor.ts ├── builder.ts ├── collections.ts ├── deno.json ├── inbox │ ├── actor.ts │ ├── following.ts │ ├── mod.ts │ └── subscribe.ts ├── mod.ts ├── nodeinfo.ts └── objects.ts ├── graphql ├── account.ts ├── actor.ts ├── ai.ts ├── builder.ts ├── db.ts ├── deno.json ├── drive.ts ├── federation.ts ├── kv.ts ├── logging.ts ├── main.ts ├── mod.ts ├── poll.ts ├── post.ts ├── reactable.ts ├── schema.graphql └── server.ts ├── mise.toml ├── models ├── account.test.ts ├── account.ts ├── actor.ts ├── article.ts ├── avatar.ts ├── blocking.ts ├── context.ts ├── date.ts ├── db.ts ├── dblogger.ts ├── deno.json ├── emoji.ts ├── following.ts ├── html.test.ts ├── html.ts ├── i18n.test.ts ├── i18n.ts ├── instance.ts ├── langdet.ts ├── markup.ts ├── medium.ts ├── note.ts ├── notification.ts ├── passkey.ts ├── poll.ts ├── post.test.ts ├── post.ts ├── reaction.ts ├── relations.ts ├── schema.ts ├── search.test.ts ├── search.ts ├── session.ts ├── signin.ts ├── signup.ts ├── timeline.ts ├── tx.ts ├── url.test.ts ├── url.ts └── uuid.ts ├── scripts ├── addaccount.ts └── keygen.ts ├── web-next ├── .env ├── .gitignore ├── app.config.ts ├── deno.jsonc ├── package.json ├── relay.config.json └── src │ ├── RelayEnvironment.tsx │ ├── app.css │ ├── app.tsx │ ├── components │ └── ui │ │ └── button.tsx │ ├── entry-client.tsx │ ├── entry-server.tsx │ ├── global.d.ts │ ├── lib │ └── utils.ts │ └── routes │ ├── [...404].tsx │ └── index.tsx └── web ├── .gitignore ├── ai.ts ├── codegen.ts ├── components ├── ActorList.tsx ├── AdminNav.tsx ├── Button.tsx ├── Excerpt.tsx ├── Input.tsx ├── InvitationLinks.tsx ├── Label.tsx ├── Msg.tsx ├── NoteExcerpt.tsx ├── PageTitle.tsx ├── PostExcerpt.tsx ├── PostPagination.tsx ├── PostReactionsNav.tsx ├── PostVisibilityIcon.tsx ├── Profile.tsx ├── ProfileNav.tsx ├── SettingsNav.tsx ├── TabNav.tsx ├── TextArea.tsx └── TimelineNav.tsx ├── db.ts ├── deno.json ├── deno.lock ├── dev.ts ├── drive.ts ├── email.ts ├── federation.ts ├── fonts ├── NotoEmoji-Regular.ttf ├── NotoSans-Regular.ttf ├── NotoSans-SemiBold.ttf ├── NotoSansJP-Regular.ttf ├── NotoSansKR-Regular.ttf ├── NotoSansSC-Regular.ttf └── NotoSansTC-Regular.ttf ├── graphql └── gql.ts ├── i18n.ts ├── islands ├── AccountLinkFieldSet.tsx ├── ArticleExcerpt.tsx ├── ArticleMetadata.tsx ├── Composer.tsx ├── ConfirmForm.tsx ├── Editor.tsx ├── InviteForm.tsx ├── Link.tsx ├── LocalePriorityList.tsx ├── MarkupTextArea.tsx ├── MediumThumbnail.tsx ├── NotificationIcon.tsx ├── PasskeyRegisterButton.tsx ├── PollCard.tsx ├── PostControls.tsx ├── QuotedPostCard.tsx ├── RecommendedActors.tsx ├── SignForm.tsx ├── TagInput.tsx └── Timestamp.tsx ├── kv.ts ├── locales ├── README.md ├── en.json ├── ja.json ├── ko.json ├── markdown │ ├── en.md │ ├── ja.md │ ├── ko.md │ ├── zh-CN.md │ └── zh-TW.md ├── search │ ├── en.md │ ├── ja.md │ ├── ko.md │ ├── zh-CN.md │ └── zh-TW.md ├── zh-CN.json └── zh-TW.json ├── logging.ts ├── main.ts ├── og.ts ├── routes ├── @[username] │ ├── [idOrYear] │ │ ├── [slug] │ │ │ ├── [lang].tsx │ │ │ ├── delete.ts │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ ├── ogimage.ts │ │ │ ├── quotes.tsx │ │ │ ├── react.ts │ │ │ ├── reactions.tsx │ │ │ ├── share.ts │ │ │ ├── shares.tsx │ │ │ └── unshare.ts │ │ ├── index.tsx │ │ ├── quotes.tsx │ │ ├── react.ts │ │ ├── reactions.tsx │ │ ├── share.ts │ │ ├── shares.tsx │ │ └── unshare.ts │ ├── articles.tsx │ ├── block.ts │ ├── drafts │ │ ├── [draftId] │ │ │ ├── delete.ts │ │ │ ├── index.tsx │ │ │ └── publish.ts │ │ ├── index.tsx │ │ └── new.ts │ ├── feed.xml.ts │ ├── follow.ts │ ├── followers.tsx │ ├── following.tsx │ ├── index.tsx │ ├── invite │ │ ├── [id] │ │ │ ├── delete.ts │ │ │ └── index.tsx │ │ └── index.ts │ ├── notes.tsx │ ├── og.ts │ ├── settings │ │ ├── index.tsx │ │ ├── invite.tsx │ │ ├── language.tsx │ │ ├── passkeys │ │ │ ├── index.tsx │ │ │ ├── options.ts │ │ │ ├── revoke.ts │ │ │ └── verify.ts │ │ └── preferences.tsx │ ├── shares.tsx │ ├── unblock.ts │ └── unfollow.ts ├── _404.tsx ├── _app.tsx ├── _middleware.ts ├── admin │ ├── _middleware.ts │ ├── index.tsx │ └── invitations.tsx ├── api │ ├── articles │ │ └── [id] │ │ │ └── content.ts │ ├── mention.ts │ ├── posts │ │ ├── [id] │ │ │ ├── index.ts │ │ │ ├── media │ │ │ │ └── [index].ts │ │ │ ├── poll.ts │ │ │ └── vote.ts │ │ └── index.ts │ └── preview.ts ├── coc.tsx ├── index.tsx ├── markdown.tsx ├── notifications.tsx ├── robots.txt.ts ├── search.tsx ├── sign │ ├── in │ │ └── [token].tsx │ ├── index.tsx │ ├── options.ts │ ├── out.ts │ ├── up │ │ └── [token].tsx │ └── verify.ts ├── sitemaps.xml.ts ├── tags │ └── [tag].tsx └── tree.tsx ├── static ├── dev-bg-dark.svg ├── dev-bg-light.svg ├── favicon-192.png ├── favicon.ico ├── favicon.svg ├── icons │ ├── activitypub.svg │ ├── bluesky.svg │ ├── codeberg.svg │ ├── dev.svg │ ├── discord.svg │ ├── facebook.svg │ ├── github.svg │ ├── gitlab.svg │ ├── hackernews.svg │ ├── hollo.svg │ ├── instagram.svg │ ├── keybase.svg │ ├── lemmy.svg │ ├── linkedin.svg │ ├── lobsters.svg │ ├── mastodon.svg │ ├── matrix.svg │ ├── misskey.svg │ ├── pixelfed.svg │ ├── pleroma.svg │ ├── qiita.svg │ ├── reddit.svg │ ├── sourcehut.svg │ ├── threads.svg │ ├── velog.svg │ ├── web.svg │ ├── wikipedia.svg │ ├── x.svg │ └── zenn.svg ├── manifest.json ├── og.png ├── og.svg └── styles.css ├── tailwind.config.ts └── utils.ts /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "enableAllProjectMcpServers": false 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | Dockerfile 3 | node_modules 4 | web/_fresh 5 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | MODE=development # development, test, production 2 | ORIGIN=https://example.com # base url of server 3 | DATABASE_URL=postgresql://localhost/hackerspub 4 | KV_URL=file:///tmp/kv.db 5 | SECRET_KEY= # openssl rand -hex 32 6 | INSTANCE_ACTOR_KEY= # deno task keygen 7 | BEHIND_PROXY=false 8 | DRIVE_DISK=fs # fs or s3 9 | # In case of fs: 10 | FS_LOCATION=./media 11 | # In case of s3: 12 | # AWS_ACCESS_KEY_ID= 13 | # AWS_SECRET_ACCESS_KEY= 14 | # S3_ENDPOINT= 15 | # S3_CDN_URL= 16 | # S3_BUCKET= 17 | # AWS_REGION= 18 | MAILGUN_KEY= 19 | MAILGUN_REGION=us 20 | MAILGUN_DOMAIN= 21 | MAILGUN_FROM= 22 | ANTHROPIC_API_KEY= 23 | GOOGLE_GENERATIVE_AI_API_KEY= 24 | PLAUSIBLE=false 25 | LOG_QUERY=false 26 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Guide for LLM-Powered Agents 2 | 3 | This file provides guidance to LLM-powered agents when working with code in this repository. 4 | 5 | ## Build/Lint/Test Commands 6 | 7 | - Build: `deno task build` 8 | - Lint/Format Check: `deno task check` 9 | - Run Dev Server: `deno task dev` 10 | - Run Tests: `deno task test` 11 | - Database Migration: `deno task migrate` 12 | - Pre-commit Hook: `deno task hooks:pre-commit` 13 | 14 | ## Code Style Guidelines 15 | 16 | ### General 17 | - Format code with `deno fmt` before submitting PRs 18 | - Use spaces for indentation (not tabs) 19 | 20 | ### Commit Messages 21 | - First line should be short and concise 22 | - Clearly describe the purpose of the changes 23 | 24 | ### Imports 25 | - External imports first, internal imports second (alphabetically within groups) 26 | - Use `type` keyword for type imports when appropriate 27 | 28 | ### Naming 29 | - camelCase for variables, functions, and methods 30 | - PascalCase for classes, interfaces, types, and components 31 | - Files with components use PascalCase (Button.tsx) 32 | - Model files use lowercase (post.ts) 33 | - Tests have a `.test.ts` suffix 34 | 35 | ### TypeScript 36 | - Use explicit typing for complex return types 37 | - Use interfaces for component props (e.g., ButtonProps) 38 | 39 | ### Components 40 | - Use functional components with props destructuring 41 | - Tailwind CSS for styling 42 | - Components in components/ directory 43 | - Interactive components in islands/ directory (Fresh framework pattern) 44 | 45 | ### Error Handling 46 | - Use structured logging via LogTape 47 | - Include context in error details 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dotenv environment variable files 2 | .env 3 | .env.development.local 4 | .env.test 5 | .env.test.local 6 | .env.production.local 7 | .env.local 8 | 9 | # Fresh build directory 10 | _fresh/ 11 | # npm + other dependencies 12 | node_modules/ 13 | /web/vendor/ 14 | 15 | # media in fs 16 | /web/media 17 | 18 | # keyv data 19 | kv.db 20 | -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: "./graphql/schema.graphql" 2 | documents: "./web/**/*.{ts,tsx}" 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "bradlc.vscode-tailwindcss", 5 | "ms-azuretools.vscode-docker", 6 | "github.vscode-github-actions", 7 | "graphql.vscode-graphql-syntax", 8 | "graphql.vscode-graphql" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "request": "launch", 6 | "name": "dev", 7 | "type": "node", 8 | "program": "${workspaceFolder}/web/dev.ts", 9 | "cwd": "${workspaceFolder}/web/", 10 | "env": {}, 11 | "runtimeExecutable": "deno", 12 | "runtimeArgs": [ 13 | "run", 14 | "-A", 15 | "--env-file=../.env", 16 | "--inspect-wait" 17 | ], 18 | "attachSimplePort": 9229 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.customData": [ 3 | ".vscode/tailwind.json" 4 | ], 5 | "cSpell.enabled": false, 6 | "deno.enable": true, 7 | "deno.lint": true, 8 | "deno.unstable": [ 9 | "temporal" 10 | ], 11 | "editor.detectIndentation": false, 12 | "editor.indentSize": 2, 13 | "editor.insertSpaces": true, 14 | "files.eol": "\n", 15 | "files.insertFinalNewline": true, 16 | "files.trimFinalNewlines": true, 17 | "[javascript]": { 18 | "editor.defaultFormatter": "denoland.vscode-deno", 19 | "editor.formatOnSave": true, 20 | "editor.codeActionsOnSave": { 21 | "source.sortImports": "always" 22 | } 23 | }, 24 | "[javascriptreact]": { 25 | "editor.defaultFormatter": "denoland.vscode-deno", 26 | "editor.formatOnSave": true, 27 | "editor.codeActionsOnSave": { 28 | "source.sortImports": "always" 29 | } 30 | }, 31 | "[json]": { 32 | "editor.defaultFormatter": "vscode.json-language-features", 33 | "editor.formatOnSave": true 34 | }, 35 | "[jsonc]": { 36 | "editor.defaultFormatter": "vscode.json-language-features", 37 | "editor.formatOnSave": true 38 | }, 39 | "[typescript]": { 40 | "editor.defaultFormatter": "denoland.vscode-deno", 41 | "editor.formatOnSave": true, 42 | "editor.codeActionsOnSave": { 43 | "source.sortImports": "always" 44 | } 45 | }, 46 | "[typescriptreact]": { 47 | "editor.defaultFormatter": "denoland.vscode-deno", 48 | "editor.formatOnSave": true, 49 | "editor.codeActionsOnSave": { 50 | "source.sortImports": "always" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.vsls.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/vsls", 3 | "gitignore": "none" 4 | } 5 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "lsp": { 3 | "deno": { 4 | "settings": { 5 | "deno": { 6 | "enable": true 7 | } 8 | } 9 | } 10 | }, 11 | "languages": { 12 | "TypeScript": { 13 | "formatter": [ 14 | { 15 | "language_server": { 16 | "name": "deno" 17 | } 18 | } 19 | ], 20 | "language_servers": [ 21 | "deno", 22 | "!typescript-language-server", 23 | "!vtsls", 24 | "!eslint", 25 | "!biome", 26 | "..." 27 | ] 28 | }, 29 | "TSX": { 30 | "formatter": [ 31 | { 32 | "language_server": { 33 | "name": "deno" 34 | } 35 | } 36 | ], 37 | "language_servers": [ 38 | "deno", 39 | "!typescript-language-server", 40 | "!vtsls", 41 | "!eslint", 42 | "!biome", 43 | "..." 44 | ] 45 | }, 46 | "JavaScript": { 47 | "formatter": [ 48 | { 49 | "language_server": { 50 | "name": "deno" 51 | } 52 | } 53 | ], 54 | "language_servers": [ 55 | "deno", 56 | "!typescript-language-server", 57 | "!vtsls", 58 | "!eslint", 59 | "!biome", 60 | "..." 61 | ] 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | CODE_OF_CONDUCT.en.md -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/denoland/deno:2.3.5 2 | 3 | RUN apt-get update && apt-get install -y build-essential curl ffmpeg jq && \ 4 | apt-get clean && rm -rf /var/lib/apt/lists/* 5 | 6 | RUN curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh && \ 7 | bash nodesource_setup.sh && \ 8 | apt-get install -y nodejs && \ 9 | rm nodesource_setup.sh && \ 10 | apt-get clean && rm -rf /var/lib/apt/lists/* 11 | 12 | WORKDIR /app 13 | COPY web/fonts /app/web/fonts 14 | 15 | COPY deno.json /app/deno.json 16 | COPY ai/deno.json /app/ai/deno.json 17 | COPY federation/deno.json /app/federation/deno.json 18 | COPY graphql/deno.json /app/graphql/deno.json 19 | COPY models/deno.json /app/models/deno.json 20 | COPY web/deno.json /app/web/deno.json 21 | COPY web-next/deno.jsonc /app/web-next/deno.jsonc 22 | COPY web-next/package.json /app/web-next/package.json 23 | COPY deno.lock /app/deno.lock 24 | 25 | RUN ["deno", "install"] 26 | 27 | COPY . /app 28 | RUN cp .env.sample .env && \ 29 | sed -i '/^INSTANCE_ACTOR_KEY=/d' .env && \ 30 | echo >> .env && \ 31 | echo "INSTANCE_ACTOR_KEY='$(deno task keygen)'" >> .env && \ 32 | deno task -r codegen && \ 33 | deno task build && \ 34 | rm .env 35 | 36 | ARG GIT_COMMIT 37 | ENV GIT_COMMIT=${GIT_COMMIT} 38 | 39 | RUN jq '.version += "+" + $git_commit' --arg git_commit $GIT_COMMIT federation/deno.json > /tmp/deno.json && \ 40 | mv /tmp/deno.json federation/deno.json && \ 41 | jq '.version += "+" + $git_commit' --arg git_commit $GIT_COMMIT graphql/deno.json > /tmp/deno.json && \ 42 | mv /tmp/deno.json graphql/deno.json && \ 43 | jq '.version += "+" + $git_commit' --arg git_commit $GIT_COMMIT models/deno.json > /tmp/deno.json && \ 44 | mv /tmp/deno.json models/deno.json && \ 45 | jq '.version += "+" + $git_commit' --arg git_commit $GIT_COMMIT web/deno.json > /tmp/deno.json && \ 46 | mv /tmp/deno.json web/deno.json 47 | 48 | EXPOSE 8000 49 | CMD ["deno", "task", "start"] 50 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM docker.io/denoland/deno:2.3.5 2 | 3 | RUN apt-get update && apt-get install -y build-essential ffmpeg jq git && \ 4 | apt-get clean && rm -rf /var/lib/apt/lists/* 5 | 6 | RUN curl -fsSL https://deb.nodesource.com/setup_23.x -o nodesource_setup.sh && \ 7 | bash nodesource_setup.sh && \ 8 | apt-get install -y nodejs && \ 9 | rm nodesource_setup.sh && \ 10 | apt-get clean && rm -rf /var/lib/apt/lists/* 11 | 12 | WORKDIR /app 13 | COPY web/fonts /app/web/fonts 14 | 15 | COPY deno.json /app/deno.json 16 | COPY ai/deno.json /app/ai/deno.json 17 | COPY federation/deno.json /app/federation/deno.json 18 | COPY graphql/deno.json /app/graphql/deno.json 19 | COPY models/deno.json /app/models/deno.json 20 | COPY web/deno.json /app/web/deno.json 21 | COPY web-next/deno.jsonc /app/web-next/deno.jsonc 22 | COPY web-next/package.json /app/web-next/package.json 23 | COPY deno.lock /app/deno.lock 24 | 25 | RUN ["deno", "install"] 26 | 27 | COPY . /app 28 | RUN cp .env.sample .env && \ 29 | sed -i '/^INSTANCE_ACTOR_KEY=/d' .env && \ 30 | echo >> .env && \ 31 | echo "INSTANCE_ACTOR_KEY='$(deno task keygen)'" >> .env && \ 32 | deno task -r codegen && \ 33 | deno task build && \ 34 | rm .env 35 | 36 | EXPOSE 8000 37 | CMD ["deno", "task", "start"] 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hackers' Pub 4 | ============ 5 | 6 | > [!NOTE] 7 | > Hackers' Pub is currently heavily under development, and it is invite-only. 8 | > If you want to sign up, please contact the author via fediverse: 9 | > [@hongminhee@hackers.pub]. 10 | 11 | Hackers' Pub is an ActivityPub-enabled social network for hackers. 12 | You can think of it as a federated version of DEV Community (또는 연합판 13 | velog, または連合できるQiita) with a Mastodon-like timeline. 14 | 15 | It focuses on the following features:[^1] 16 | 17 | - Federated via ActivityPub: You can follow and interact with users on other 18 | servers that support ActivityPub, such as Mastodon, Misskey, and Akkoma. 19 | 20 | - Markdown with extensions: You can write posts and comments in Markdown with 21 | extensions, such as tables, footnotes, callouts, diagrams, math, and more. 22 | 23 | - Powerful code blocks: You can write code blocks with syntax highlighting, 24 | highlighting lines, focusing lines, diff highlighting, and more. 25 | 26 | - Multilingual: You can write posts in many languages, not just English. 27 | You don't speak Chinese or Spanish? No problem! Hackers' Pub supports 28 | automatic translation of posts and comments. 29 | 30 | - Algorithimic timeline: You can see posts from users you follow in a 31 | timeline. The timeline is sorted by the relevance of the posts by default, 32 | but you can change the sorting order to the latest if you want. 33 | 34 | Hackers' Pub is open source. The source code is available under the AGPL-3.0. 35 | 36 | [^1]: Of course, these features are not implemented yet. This is just a plan. 37 | 38 | [@hongminhee@hackers.pub]: https://hackers.pub/@hongminhee 39 | -------------------------------------------------------------------------------- /ai/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerspub/ai", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts", 6 | "./summary": "./summary.ts", 7 | "./translate": "./translate.ts" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ai/language.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackers-pub/hackerspub/82dea9c09897c5704fa67190708141f64477c069/ai/language.ts -------------------------------------------------------------------------------- /ai/mod.ts: -------------------------------------------------------------------------------- 1 | export { summarize } from "./summary.ts"; 2 | export { translate } from "./translate.ts"; 3 | -------------------------------------------------------------------------------- /ai/prompts/summary/ja.md: -------------------------------------------------------------------------------- 1 | # 技術投稿の自動要約システムプロンプト 2 | 3 | あなたは技術ブログの投稿を要約するAIアシスタントです。Markdown形式の技術投稿を明確かつ簡潔に要約する必要があります。この要約の主な目的は、読者が全文を読みたくなるよう導くことです。 4 | 5 | ## 要約の目的 6 | 7 | - 要約は投稿の核心を伝えつつも、すべての詳細内容を含めるべきではありません。 8 | - 読者が「もっと読みたい」と感じるよう興味を引き起こすべきです。 9 | - 技術的内容についての簡潔な紹介を提供しつつも、すべての技術的詳細情報は含めません。 10 | 11 | ## 要約のガイドライン 12 | 13 | - 各投稿はMarkdown形式であり、フロントマターは含まれていません。 14 | - すべての要約は{{targetLanguage}}で作成する必要があります。 15 | - 長い記事(400単語以上)の場合は、150〜200単語程度の簡潔な単一テキストブロックとして要約を生成してください。 16 | - 短い記事(400単語未満)の場合は、50〜100単語程度の非常に簡潔な要約を生成してください。 17 | - 要約は必ず元の記事よりもはるかに短くなければならず、決して元の記事より長くなってはいけません。 18 | - 元の記事の長さに比例して要約の長さを調整してください。 19 | - 小見出しや構造的分離なしに、一つの連続した段落として作成してください。 20 | - コードブロックは含めないでください。代わりに、コードが扱う概念や問題を簡潔に説明してください。 21 | - 核心的な技術概念と主要なアイデアを簡潔に含めてください。 22 | - 技術用語やライブラリ/フレームワーク名を正確に維持してください。 23 | - 著者の視点と主な発見を簡潔に含めつつも、詳細な方法論は省略してください。 24 | - 記事の価値と読者が得られるインサイトに焦点を当ててください。 25 | 26 | ## 出力形式 27 | 28 | 要約は以下のような形式に従う必要があります: 29 | 30 | - 小見出しや区分なしに、一つの段落で構成 31 | - コードブロックやリストなしに純粋なテキストのみで作成 32 | - 最初の文で記事の核心テーマを紹介 33 | - 最後の文で記事の価値や重要性に言及 34 | 35 | ## 表記法および文法 36 | 37 | - 要約は必ず{{targetLanguage}}で作成する必要があります。 38 | - 固有名詞(例:JavaScript、TypeScript、React)は原語のまま表記してください。 39 | - 一般的な技術用語は日本語での一般的な表記に従ってください(例:フレームワーク、インターフェース、ライブラリ)。 40 | - 専門用語が初めて登場する際は、日本語表記の後に括弧内で原語を併記することができます(例:依存性注入(dependency injection))。 41 | - 句読点やスペースは日本語の文法規則に従って使用してください。 42 | - 技術用語を翻訳する際に、業界で広く通用している日本語表現があればそれを使用してください。 43 | - 略語(例:API、HTTP、REST)は原文通り大文字で表記してください。 44 | - 文章は簡潔かつ明確に作成し、不必要な修飾語を避けてください。 45 | - 原文が英語や他の言語で書かれていても、要約は常に日本語に翻訳して作成してください。 46 | 47 | ## 特別な指示 48 | 49 | - 技術的に正確でありながらも、専門的すぎない文章を作成してください。 50 | - 記事のすべての内容を盛り込もうとせず、読者の好奇心を刺激する主要なポイントのみを含めてください。 51 | - 「~を紹介します」、「~を説明します」などのメタ的表現はなるべく避けてください。 52 | - 要約は直接的かつ能動的な調子で作成してください。 53 | - 読者に「記事を読んでください」などの直接的な勧誘は含めないでください。 54 | - 非常に短い記事の場合は、最も重要なポイントのみを抽出し、極めて簡潔にまとめてください。 55 | - 要約は常に元の記事よりも少なくとも50%短くなるよう心がけてください。 56 | - 元の記事が既に短い場合は、要約をさらに簡潔にしてください。 57 | 58 | あなたの要約は技術的内容の核心を簡潔に伝えつつも、読者が全投稿を読みたくなるよう興味を引き起こすべきです。元の記事の長さに関わらず、良い要約は常に元の記事よりも短くなければならないことを忘れないでください。 59 | -------------------------------------------------------------------------------- /ai/prompts/summary/ko.md: -------------------------------------------------------------------------------- 1 | # 기술 포스팅 자동 요약을 위한 시스템 프롬프트 2 | 3 | 당신은 기술 블로그의 포스팅을 요약하는 AI 어시스턴트입니다. 당신에게 제공된 Markdown 형식의 기술 포스팅을 명확하고 간결하게 요약해야 합니다. 이 요약은 독자가 전체 글을 읽고 싶도록 유도하는 것이 주요 목적입니다. 4 | 5 | ## 요약 목적 6 | 7 | - 요약은 포스팅의 핵심을 전달하되, 모든 세부 내용을 담지 않아야 합니다. 8 | - 독자가 "이 글을 더 읽어보고 싶다"고 느끼도록 흥미를 유발해야 합니다. 9 | - 기술적 내용에 대한 간략한 소개를 제공하되, 모든 기술적 상세 정보를 담지는 않습니다. 10 | 11 | ## 요약 지침 12 | 13 | - 각 포스팅은 Markdown 형식으로 되어 있으며, 프론트매터는 포함되어 있지 않습니다. 14 | - 요약문은 {{targetLanguage}}로 작성해야 합니다. 15 | - 긴 글(400단어 이상)의 경우 150–200단어 내외의 간결한 단일 텍스트 블록으로 요약을 생성하세요. 16 | - 짧은 글(400단어 미만)의 경우 50-100단어 내외의 매우 간결한 요약을 생성하세요. 17 | - 요약문은 반드시 원문보다 훨씬 짧아야 하며, 절대로 원문보다 길어서는 안 됩니다. 18 | - 원문의 길이에 비례하여 요약문의 길이를 조정하세요. 19 | - 소제목이나 구조적 분리 없이 하나의 연속된 문단으로 작성하세요. 20 | - 코드 블록은 포함하지 마세요. 대신 코드가 다루는 개념이나 문제를 간략히 설명하세요. 21 | - 핵심적인 기술 개념과 주요 아이디어를 간략하게 포함하세요. 22 | - 기술 용어와 라이브러리/프레임워크 이름을 정확하게 유지하세요. 23 | - 저자의 관점과 주요 발견을 간략히 포함하되 상세한 방법론은 생략하세요. 24 | - 글의 가치와 독자가 얻을 수 있는 인사이트에 초점을 맞추세요. 25 | 26 | ## 출력 형식 27 | 28 | 요약은 다음과 같은 형식을 따라야 합니다: 29 | 30 | - 소제목이나 구분 없이 한 개의 단락으로 구성 31 | - 코드 블록이나 리스트 없이 순수 텍스트로만 작성 32 | - 시작 문장에서 글의 핵심 주제를 소개 33 | - 마지막 문장에서 글의 가치나 중요성을 언급 34 | 35 | ## 표기법 및 맞춤법 36 | 37 | - 요약문은 반드시 {{targetLanguage}}로 작성해야 합니다. 38 | - 고유 명사(예: JavaScript, TypeScript, React)는 원어 그대로 표기하세요. 39 | - 일반 명사인 외래어는 한글 외래어 표기법에 따라 한글로 표기하세요(예: 프레임워크, 인터페이스, 라이브러리). 40 | - 전문 용어가 처음 등장할 때는 한글 표기 후 괄호 안에 원어를 함께 표기할 수 있습니다(예: 의존성 주입(dependency injection)). 41 | - 문장 부호와 띄어쓰기는 한글 맞춤법에 맞게 사용하세요. 42 | - 기술 용어를 번역할 때 이미 업계에서 널리 통용되는 한글 표현이 있다면 그것을 사용하세요. 43 | - 약어(예: API, HTTP, REST)는 원문 그대로 대문자로 표기하세요. 44 | - 문장은 간결하고 명확하게 작성하며, 불필요한 수식어를 피하세요. 45 | - 원문이 영어나 다른 언어로 작성되었더라도, 요약문은 항상 한국어로 번역하여 작성하세요. 46 | 47 | ## 특별 지침 48 | 49 | - 기술적으로 정확하면서도 너무 전문적이지 않게 작성하세요. 50 | - 글의 모든 내용을 담으려 하지 말고, 독자의 호기심을 자극하는 주요 포인트만 포함하세요. 51 | - "~을 소개합니다", "~을 설명합니다" 같은 메타적 표현은 가급적 피하세요. 52 | - 요약은 직접적이고 능동적인 어조로 작성하세요. 53 | - 독자에게 "글을 읽어보세요"와 같은 직접적인 권유는 포함하지 마세요. 54 | - 매우 짧은 글의 경우, 가장 핵심적인 요점만 추출하여 극도로 간결하게 작성하세요. 55 | - 요약문은 항상 원문보다 최소 50% 이상 짧아야 합니다. 56 | - 원문 자체가 이미 짧은 경우, 요약문은 더욱 간결하게 작성하세요. 57 | 58 | 당신의 요약은 기술적 내용의 핵심을 간결하게 전달하면서도, 독자가 전체 포스팅을 읽고 싶도록 흥미를 유발해야 합니다. 원문의 길이와 상관없이 좋은 요약문은 항상 원문보다 짧아야 함을 기억하세요. 59 | -------------------------------------------------------------------------------- /ai/prompts/summary/zh-CN.md: -------------------------------------------------------------------------------- 1 | # 技术文章自动摘要系统提示 2 | 3 | 你是一个负责总结技术博客文章的AI助手。你需要清晰简洁地总结提供给你的Markdown格式技术文章。这个摘要的主要目的是引导读者阅读全文。 4 | 5 | ## 摘要目的 6 | 7 | - 摘要应传达文章核心,但不包含所有细节内容。 8 | - 应引起读者兴趣,让读者感到"我想阅读更多关于这个主题"。 9 | - 提供技术内容的简短介绍,但不包含所有技术细节。 10 | 11 | ## 摘要指南 12 | 13 | - 每篇文章均为Markdown格式,且不包含前置数据。 14 | - 所有摘要必须以简体{{targetLanguage}}撰写。 15 | - 对于较长文章(超过400字),生成约150-200字的简洁单一文本块作为摘要。 16 | - 对于较短文章(少于400字),生成约50-100字的极简摘要。 17 | - 摘要必须始终比原文显著短,绝不能比原文更长。 18 | - 根据原文长度相应调整摘要长度。 19 | - 撰写为一个连续段落,不使用小标题或结构性分隔。 20 | - 不要包含代码块。取而代之,简略解释代码所处理的概念或问题。 21 | - 简要包含核心技术概念和主要想法。 22 | - 准确保留技术术语和库/框架名称。 23 | - 简要包含作者观点和主要发现,但省略详细方法论。 24 | - 聚焦于文章价值和读者可获得的见解。 25 | 26 | ## 输出格式 27 | 28 | 摘要应遵循以下格式: 29 | 30 | - 由一个段落组成,没有小标题或分隔 31 | - 纯文本内容,不含代码块或列表 32 | - 开头句介绍文章核心主题 33 | - 结尾句提及文章价值或重要性 34 | 35 | ## 标记法及语法 36 | 37 | - 摘要必须以简体{{targetLanguage}}撰写。 38 | - 专有名词(如:JavaScript、TypeScript、React)保持原文表示。 39 | - 一般外来术语应使用中文常见表达方式(如:框架、接口、库)。 40 | - 专业术语首次出现时,可以中文表示后在括号内附上原文(如:依赖注入(dependency injection)。)。 41 | - 使用符合中文语法规则的标点符号和间距。 42 | - 翻译技术术语时,如果业界已有广泛使用的中文表达,请使用该表达。 43 | - 缩写(如:API、HTTP、REST)应保持原文大写形式。 44 | - 句子应简洁明确,避免不必要的修饰词。 45 | - 即使原文是英文或其他语言,摘要也必须翻译成简体中文。 46 | 47 | ## 特别指示 48 | 49 | - 技术内容准确但不过于专业,确保易于理解。 50 | - 不要试图涵盖所有内容,只专注于能引起读者好奇心的关键点。 51 | - 避免使用"本文介绍"、"本文说明"等元表达。 52 | - 使用直接且主动的语调撰写摘要。 53 | - 不要包含"请阅读此文"等直接邀请。 54 | - 对于非常简短的内容,只提取最核心的要点,做极致精简。 55 | - 确保摘要始终比原文至少短50%。 56 | - 如果原文本身已经很短,则使摘要更加简短精炼。 57 | 58 | 你的摘要应简洁传达技术内容的精髓,同时引起读者阅读全文的兴趣。无论原文长度如何,好的摘要始终应比原文更短,这一点请务必牢记。 59 | -------------------------------------------------------------------------------- /ai/prompts/summary/zh-TW.md: -------------------------------------------------------------------------------- 1 | # 技術文章自動摘要系統提示 2 | 3 | 你是一個負責摘要技術部落格文章的AI助手。你需要清晰簡潔地摘要提供給你的Markdown格式技術文章。這個摘要的主要目的是引導讀者閱讀全文。 4 | 5 | ## 摘要目的 6 | 7 | - 摘要應傳達文章核心,但不包含所有細節內容。 8 | - 應引起讀者興趣,讓讀者感到「我想閱讀更多關於這個主題」。 9 | - 提供技術內容的簡短介紹,但不包含所有技術細節。 10 | 11 | ## 摘要指南 12 | 13 | - 每篇文章均為Markdown格式,且不包含前置資料。 14 | - 所有摘要必須以繁體{{targetLanguage}}撰寫。 15 | - 對於較長文章(超過400字),生成約150-200字的簡潔單一文字區塊作為摘要。 16 | - 對於較短文章(少於400字),生成約50-100字的極簡摘要。 17 | - 摘要必須始終比原文顯著短,絕不能比原文更長。 18 | - 根據原文長度相應調整摘要長度。 19 | - 撰寫為一個連續段落,不使用小標題或結構性分隔。 20 | - 不要包含程式碼區塊。取而代之,簡略解釋程式碼所處理的概念或問題。 21 | - 簡要包含核心技術概念和主要想法。 22 | - 準確保留技術術語和程式庫/框架名稱。 23 | - 簡要包含作者觀點和主要發現,但省略詳細方法論。 24 | - 聚焦於文章價值和讀者可獲得的見解。 25 | 26 | ## 輸出格式 27 | 28 | 摘要應遵循以下格式: 29 | 30 | - 由一個段落組成,沒有小標題或分隔 31 | - 純文字內容,不含程式碼區塊或列表 32 | - 開頭句介紹文章核心主題 33 | - 結尾句提及文章價值或重要性 34 | 35 | ## 標記法及語法 36 | 37 | - 摘要必須以繁體{{targetLanguage}}撰寫。 38 | - 專有名詞(如:JavaScript、TypeScript、React)保持原文表示。 39 | - 一般外來術語應使用中文常見表達方式(如:框架、介面、函式庫)。 40 | - 專業術語首次出現時,可以中文表示後在括號內附上原文(如:依賴注入(dependency injection))。 41 | - 使用符合中文語法規則的標點符號和間距。 42 | - 翻譯技術術語時,如果業界已有廣泛使用的中文表達,請使用該表達。 43 | - 縮寫(如:API、HTTP、REST)應保持原文大寫形式。 44 | - 句子應簡潔明確,避免不必要的修飾詞。 45 | - 即使原文是英文或其他語言,摘要也必須翻譯成繁體中文。 46 | 47 | ## 特別指示 48 | 49 | - 技術內容準確但不過於專業,確保易於理解。 50 | - 不要試圖涵蓋所有內容,只專注於能引起讀者好奇心的關鍵點。 51 | - 避免使用「本文介紹」、「本文說明」等元表達。 52 | - 使用直接且主動的語調撰寫摘要。 53 | - 不要包含「請閱讀此文」等直接邀請。 54 | - 對於非常簡短的內容,只提取最核心的要點,做極致精簡。 55 | - 確保摘要始終比原文至少短50%。 56 | - 如果原文本身已經很短,則使摘要更加簡短精煉。 57 | 58 | 你的摘要應簡潔傳達技術內容的精髓,同時引起讀者閱讀全文的興趣。無論原文長度如何,好的摘要始終應比原文更短,這一點請務必牢記。 59 | -------------------------------------------------------------------------------- /ai/summary.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findNearestLocale, 3 | isLocale, 4 | type Locale, 5 | } from "@hackerspub/models/i18n"; 6 | import { join } from "@std/path/join"; 7 | import { generateText, type LanguageModelV1 } from "ai"; 8 | 9 | const PROMPT_LANGUAGES: Locale[] = ( 10 | await Array.fromAsync( 11 | Deno.readDir(join(import.meta.dirname!, "prompts", "summary")), 12 | ) 13 | ).map((f) => f.name.replace(/\.md$/, "")).filter(isLocale); 14 | 15 | async function getSummaryPrompt( 16 | sourceLanguage: string, 17 | targetLanguage: string, 18 | ): Promise { 19 | const promptLanguage = findNearestLocale(targetLanguage, PROMPT_LANGUAGES) ?? 20 | findNearestLocale(sourceLanguage, PROMPT_LANGUAGES) ?? "en"; 21 | const promptPath = join( 22 | import.meta.dirname!, 23 | "prompts", 24 | "summary", 25 | `${promptLanguage}.md`, 26 | ); 27 | const promptTemplate = await Deno.readTextFile(promptPath); 28 | const displayNames = new Intl.DisplayNames(promptLanguage, { 29 | type: "language", 30 | }); 31 | return promptTemplate.replaceAll( 32 | "{{targetLanguage}}", 33 | displayNames.of(targetLanguage) ?? targetLanguage, 34 | ); 35 | } 36 | 37 | export interface SummaryOptions { 38 | model: LanguageModelV1; 39 | sourceLanguage: string; 40 | targetLanguage: string; 41 | text: string; 42 | } 43 | 44 | export async function summarize(options: SummaryOptions): Promise { 45 | const system = await getSummaryPrompt( 46 | options.sourceLanguage, 47 | options.targetLanguage, 48 | ); 49 | const { text } = await generateText({ 50 | model: options.model, 51 | system, 52 | prompt: options.text, 53 | }); 54 | return text; 55 | } 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | environment: 9 | DATABASE_URL: postgresql://postgres:password@db:5432/hackerspub 10 | KV_URL: file:///tmp/kv.db 11 | SECRET_KEY: ${SECRET_KEY} 12 | BEHIND_PROXY: ${BEHIND_PROXY} 13 | MAILGUN_KEY: ${MAILGUN_KEY} 14 | MAILGUN_REGION: ${MAILGUN_REGION} 15 | MAILGUN_DOMAIN: ${MAILGUN_DOMAIN} 16 | MAILGUN_FROM: ${MAILGUN_FROM} 17 | SENTRY_DSN: ${SENTRY_DSN} 18 | PLAUSIBLE: ${PLAUSIBLE} 19 | ports: 20 | - "8000:8000" 21 | depends_on: 22 | - db 23 | command: deno task dev 24 | volumes: 25 | - .:/app 26 | - /app/deps 27 | - /app/.cache 28 | - /app/.deno 29 | 30 | db: 31 | image: postgres:17 32 | environment: 33 | POSTGRES_USER: postgres 34 | POSTGRES_PASSWORD: password 35 | POSTGRES_DB: hackerspub 36 | volumes: 37 | - db_data:/var/lib/postgresql/data 38 | 39 | volumes: 40 | db_data: 41 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | const DATABASE_URL = process.env.DATABASE_URL; 5 | if (DATABASE_URL == null) { 6 | throw new Error("Missing DATABASE_URL environment variable."); 7 | } 8 | 9 | export default defineConfig({ 10 | out: "./drizzle", 11 | schema: "./models/schema.ts", 12 | dialect: "postgresql", 13 | dbCredentials: { 14 | url: DATABASE_URL, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /drizzle/0001_account_key.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."account_key_type" AS ENUM('Ed25519', 'RSASSA-PKCS1-v1_5');--> statement-breakpoint 2 | CREATE TABLE IF NOT EXISTS "account_key" ( 3 | "account_id" uuid NOT NULL, 4 | "type" "account_key_type" NOT NULL, 5 | "public" jsonb NOT NULL, 6 | "private" jsonb NOT NULL, 7 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 8 | CONSTRAINT "account_key_account_id_type_pk" PRIMARY KEY("account_id","type"), 9 | CONSTRAINT "account_key_public_check" CHECK ("account_key"."public" IS JSON OBJECT), 10 | CONSTRAINT "account_key_private_check" CHECK ("account_key"."private" IS JSON OBJECT) 11 | ); 12 | --> statement-breakpoint 13 | DO $$ BEGIN 14 | ALTER TABLE "account_key" ADD CONSTRAINT "account_key_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action; 15 | EXCEPTION 16 | WHEN duplicate_object THEN null; 17 | END $$; 18 | --> statement-breakpoint 19 | ALTER TABLE "account_email" DROP COLUMN IF EXISTS "updated";--> statement-breakpoint 20 | ALTER TABLE "account_email" DROP COLUMN IF EXISTS "deleted";--> statement-breakpoint 21 | ALTER TABLE "account_link" DROP COLUMN IF EXISTS "updated";--> statement-breakpoint 22 | ALTER TABLE "account_link" DROP COLUMN IF EXISTS "deleted"; -------------------------------------------------------------------------------- /drizzle/0002_account.username_changed.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "username_changed" timestamp with time zone; -------------------------------------------------------------------------------- /drizzle/0003_account_link.icon.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."account_link_icon" AS ENUM('activitypub', 'bluesky', 'codeberg', 'dev', 'discord', 'facebook', 'github', 'gitlab', 'hackernews', 'hollo', 'instagram', 'keybase', 'lemmy', 'linkedin', 'lobsters', 'mastodon', 'matrix', 'misskey', 'pixelfed', 'pleroma', 'qiita', 'reddit', 'sourcehut', 'threads', 'velog', 'web', 'wikipedia', 'x', 'zenn');--> statement-breakpoint 2 | ALTER TABLE "account_link" ADD COLUMN "icon" "account_link_icon" DEFAULT 'web' NOT NULL; -------------------------------------------------------------------------------- /drizzle/0005_actor.published.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ADD COLUMN "published" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL; -------------------------------------------------------------------------------- /drizzle/0006_actor.published__nulable.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ALTER COLUMN "published" DROP DEFAULT;--> statement-breakpoint 2 | ALTER TABLE "actor" ALTER COLUMN "published" DROP NOT NULL; -------------------------------------------------------------------------------- /drizzle/0007_actor__and__instance__checks.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ADD CONSTRAINT "actor_username_check" CHECK ("actor"."username" NOT LIKE '%@%');--> statement-breakpoint 2 | ALTER TABLE "instance" ADD CONSTRAINT "instance_host_check" CHECK ("instance"."host" NOT LIKE '%@%'); -------------------------------------------------------------------------------- /drizzle/0008_article_draft.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "article_draft" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "title" text NOT NULL, 5 | "content" text NOT NULL, 6 | "tags" text[] DEFAULT (ARRAY[]::text[]) NOT NULL, 7 | "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 8 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "article_draft" ADD CONSTRAINT "article_draft_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | -------------------------------------------------------------------------------- /drizzle/0009_article_source.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "article_source" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "published_year" smallint DEFAULT EXTRACT(year FROM CURRENT_TIMESTAMP) NOT NULL, 5 | "slug" varchar(128) NOT NULL, 6 | "title" text NOT NULL, 7 | "content" text NOT NULL, 8 | "tags" text[] DEFAULT (ARRAY[]::text[]) NOT NULL, 9 | "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 10 | "published" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 11 | ); 12 | --> statement-breakpoint 13 | ALTER TABLE "article_draft" ADD COLUMN "article_source_id" uuid;--> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "article_source" ADD CONSTRAINT "article_source_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "article_draft" ADD CONSTRAINT "article_draft_article_source_id_article_source_id_fk" FOREIGN KEY ("article_source_id") REFERENCES "public"."article_source"("id") ON DELETE no action ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | -------------------------------------------------------------------------------- /drizzle/0010_article_source.language.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_source" ADD COLUMN "language" varchar NOT NULL; -------------------------------------------------------------------------------- /drizzle/0011_article_source__check.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_source" ADD CONSTRAINT "article_source_account_id_published_year_slug_unique" UNIQUE("account_id","published_year","slug"); -------------------------------------------------------------------------------- /drizzle/0012_article_source__check.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_source" ADD CONSTRAINT "article_source_published_year_check" CHECK ("article_source"."published_year" = EXTRACT(year FROM "article_source"."published")); -------------------------------------------------------------------------------- /drizzle/0013_following.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "following" ( 2 | "iri" text PRIMARY KEY NOT NULL, 3 | "follower_id" uuid NOT NULL, 4 | "followee_id" uuid NOT NULL, 5 | "accepted" timestamp with time zone, 6 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | CONSTRAINT "following_follower_id_followee_id_unique" UNIQUE("follower_id","followee_id") 8 | ); 9 | --> statement-breakpoint 10 | DO $$ BEGIN 11 | ALTER TABLE "following" ADD CONSTRAINT "following_follower_id_actor_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."actor"("id") ON DELETE no action ON UPDATE no action; 12 | EXCEPTION 13 | WHEN duplicate_object THEN null; 14 | END $$; 15 | --> statement-breakpoint 16 | DO $$ BEGIN 17 | ALTER TABLE "following" ADD CONSTRAINT "following_followee_id_actor_id_fk" FOREIGN KEY ("followee_id") REFERENCES "public"."actor"("id") ON DELETE no action ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; 21 | -------------------------------------------------------------------------------- /drizzle/0015_post.name.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" RENAME COLUMN "summary" TO "name"; -------------------------------------------------------------------------------- /drizzle/0016_post_article_source_id_check.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" DROP CONSTRAINT "post_article_source_id_check";--> statement-breakpoint 2 | ALTER TABLE "post" ADD CONSTRAINT "post_article_source_id_check" CHECK ("post"."type" = 'Article' OR "post"."article_source_id" IS NULL); -------------------------------------------------------------------------------- /drizzle/0017_mention.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "mention" ( 2 | "post_id" uuid NOT NULL, 3 | "actor_id" uuid NOT NULL, 4 | CONSTRAINT "mention_post_id_actor_id_pk" PRIMARY KEY("post_id","actor_id") 5 | ); 6 | --> statement-breakpoint 7 | DO $$ BEGIN 8 | ALTER TABLE "mention" ADD CONSTRAINT "mention_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action; 9 | EXCEPTION 10 | WHEN duplicate_object THEN null; 11 | END $$; 12 | --> statement-breakpoint 13 | DO $$ BEGIN 14 | ALTER TABLE "mention" ADD CONSTRAINT "mention_actor_id_actor_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action; 15 | EXCEPTION 16 | WHEN duplicate_object THEN null; 17 | END $$; 18 | -------------------------------------------------------------------------------- /drizzle/0018_get-rid-of-account.deleted.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" DROP COLUMN IF EXISTS "deleted"; -------------------------------------------------------------------------------- /drizzle/0019_note_source.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "note_source" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "content" text NOT NULL, 5 | "language" varchar NOT NULL, 6 | "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | "published" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | ALTER TABLE "post" ADD COLUMN "note_source_id" uuid;--> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "note_source" ADD CONSTRAINT "note_source_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | --> statement-breakpoint 17 | DO $$ BEGIN 18 | ALTER TABLE "post" ADD CONSTRAINT "post_note_source_id_note_source_id_fk" FOREIGN KEY ("note_source_id") REFERENCES "public"."note_source"("id") ON DELETE cascade ON UPDATE no action; 19 | EXCEPTION 20 | WHEN duplicate_object THEN null; 21 | END $$; 22 | --> statement-breakpoint 23 | ALTER TABLE "post" ADD CONSTRAINT "post_note_source_id_unique" UNIQUE("note_source_id");--> statement-breakpoint 24 | ALTER TABLE "post" ADD CONSTRAINT "post_note_source_id_check" CHECK ("post"."type" = 'Note' OR "post"."type" = 'Question' OR "post"."note_source_id" IS NULL); -------------------------------------------------------------------------------- /drizzle/0020_post_note_source_id_check.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" DROP CONSTRAINT "post_note_source_id_check";--> statement-breakpoint 2 | ALTER TABLE "post" ADD CONSTRAINT "post_note_source_id_check" CHECK ("post"."type" = 'Note' OR "post"."note_source_id" IS NULL); -------------------------------------------------------------------------------- /drizzle/0021_allowed_email.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."account_link_icon" ADD VALUE 'akkoma' BEFORE 'bluesky';--> statement-breakpoint 2 | CREATE TABLE IF NOT EXISTS "allowed_email" ( 3 | "email" text PRIMARY KEY NOT NULL, 4 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /drizzle/0022_post_visibility.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."post_visibility" AS ENUM('public', 'unlisted', 'followers', 'direct', 'none');--> statement-breakpoint 2 | ALTER TABLE "note_source" ADD COLUMN "visibility" "post_visibility" DEFAULT 'public' NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "post" ADD COLUMN "visibility" "post_visibility" DEFAULT 'unlisted' NOT NULL; -------------------------------------------------------------------------------- /drizzle/0023_medium.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."medium_type" AS ENUM('image/gif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp');--> statement-breakpoint 2 | CREATE TABLE IF NOT EXISTS "medium" ( 3 | "post_id" uuid NOT NULL, 4 | "index" smallint NOT NULL, 5 | "type" "medium_type" NOT NULL, 6 | "url" text NOT NULL, 7 | "alt" text, 8 | "width" integer, 9 | "height" integer, 10 | "sensitive" boolean DEFAULT false NOT NULL, 11 | CONSTRAINT "medium_post_id_index_pk" PRIMARY KEY("post_id","index"), 12 | CONSTRAINT "medium_index_check" CHECK ("medium"."index" >= 0), 13 | CONSTRAINT "medium_url_check" CHECK ("medium"."url" ~ '^https?://'), 14 | CONSTRAINT "medium_width_height_check" CHECK ( 15 | CASE 16 | WHEN "medium"."width" IS NULL THEN "medium"."height" IS NULL 17 | ELSE "medium"."height" IS NOT NULL AND 18 | "medium"."width" > 0 AND "medium"."height" > 0 19 | END 20 | ) 21 | ); 22 | --> statement-breakpoint 23 | DO $$ BEGIN 24 | ALTER TABLE "medium" ADD CONSTRAINT "medium_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action; 25 | EXCEPTION 26 | WHEN duplicate_object THEN null; 27 | END $$; 28 | -------------------------------------------------------------------------------- /drizzle/0024_unique_post.actor_id_shared_post_id.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "post" WHERE "post"."id" NOT IN ( 2 | SELECT any_value("post"."id") 3 | FROM "post" 4 | WHERE "post"."shared_post_id" IS NOT NULL 5 | GROUP BY "post"."actor_id", "post"."shared_post_id" 6 | ) AND "post"."shared_post_id" IS NOT NULL; 7 | --> statement-breakpoint 8 | ALTER TABLE "post" ADD CONSTRAINT "post_actor_id_shared_post_id_unique" UNIQUE("actor_id","shared_post_id"); 9 | -------------------------------------------------------------------------------- /drizzle/0025_account.avatar_key.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "avatar_key" text;--> statement-breakpoint 2 | ALTER TABLE "account" ADD CONSTRAINT "account_avatar_key_unique" UNIQUE("avatar_key"); -------------------------------------------------------------------------------- /drizzle/0026_rename-medium-to-post_medium.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "medium" RENAME TO "post_medium";--> statement-breakpoint 2 | ALTER TABLE "post_medium" DROP CONSTRAINT "medium_index_check";--> statement-breakpoint 3 | ALTER TABLE "post_medium" DROP CONSTRAINT "medium_url_check";--> statement-breakpoint 4 | ALTER TABLE "post_medium" DROP CONSTRAINT "medium_width_height_check";--> statement-breakpoint 5 | ALTER TABLE "post_medium" DROP CONSTRAINT "medium_post_id_post_id_fk"; 6 | --> statement-breakpoint 7 | ALTER TABLE "post_medium" DROP CONSTRAINT "medium_post_id_index_pk";--> statement-breakpoint 8 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_post_id_index_pk" PRIMARY KEY("post_id","index");--> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | --> statement-breakpoint 15 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_index_check" CHECK ("post_medium"."index" >= 0);--> statement-breakpoint 16 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_url_check" CHECK ("post_medium"."url" ~ '^https?://');--> statement-breakpoint 17 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_width_height_check" CHECK ( 18 | CASE 19 | WHEN "post_medium"."width" IS NULL THEN "post_medium"."height" IS NULL 20 | ELSE "post_medium"."height" IS NOT NULL AND 21 | "post_medium"."width" > 0 AND "post_medium"."height" > 0 22 | END 23 | ); -------------------------------------------------------------------------------- /drizzle/0027_rename-medium_type-to-post_medium_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."medium_type" RENAME TO "post_medium_type"; -------------------------------------------------------------------------------- /drizzle/0028_note_medium.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "note_medium" ( 2 | "note_source_id" uuid NOT NULL, 3 | "index" smallint NOT NULL, 4 | "key" text NOT NULL, 5 | "alt" text NOT NULL, 6 | "width" integer NOT NULL, 7 | "height" integer NOT NULL, 8 | CONSTRAINT "note_medium_note_source_id_index_pk" PRIMARY KEY("note_source_id","index"), 9 | CONSTRAINT "note_medium_key_unique" UNIQUE("key") 10 | ); 11 | --> statement-breakpoint 12 | DO $$ BEGIN 13 | ALTER TABLE "note_medium" ADD CONSTRAINT "note_medium_note_source_id_note_source_id_fk" FOREIGN KEY ("note_source_id") REFERENCES "public"."note_source"("id") ON DELETE cascade ON UPDATE no action; 14 | EXCEPTION 15 | WHEN duplicate_object THEN null; 16 | END $$; 17 | -------------------------------------------------------------------------------- /drizzle/0029_account.old_username.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "oldUsername" varchar(50); -------------------------------------------------------------------------------- /drizzle/0030_account.moderator.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "moderator" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0031_rename-account.old_username.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" RENAME COLUMN "oldUsername" TO "old_username"; -------------------------------------------------------------------------------- /drizzle/0032_og_image_key.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "og_image_key" text;--> statement-breakpoint 2 | ALTER TABLE "article_source" ADD COLUMN "og_image_key" text;--> statement-breakpoint 3 | ALTER TABLE "account" ADD CONSTRAINT "account_og_image_key_unique" UNIQUE("og_image_key");--> statement-breakpoint 4 | ALTER TABLE "article_source" ADD CONSTRAINT "article_source_og_image_key_unique" UNIQUE("og_image_key"); -------------------------------------------------------------------------------- /drizzle/0034_delete-orphan-note-sources.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM note_source 2 | WHERE note_source.id NOT IN ( 3 | SELECT post.note_source_id 4 | FROM post 5 | WHERE post.note_source_id IS NOT NULL 6 | ); 7 | DELETE FROM article_source 8 | WHERE article_source.id NOT IN ( 9 | SELECT post.article_source_id 10 | FROM post 11 | WHERE post.article_source_id IS NOT NULL 12 | ); 13 | -------------------------------------------------------------------------------- /drizzle/0035_account.locales.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "locales" varchar[]; -------------------------------------------------------------------------------- /drizzle/0036_post.summary.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" ADD COLUMN "summary" text; -------------------------------------------------------------------------------- /drizzle/0037_post_link.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "post_link" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "url" text NOT NULL, 4 | "title" text, 5 | "type" text, 6 | "description" text, 7 | "image_url" text, 8 | "image_type" text, 9 | "image_width" integer, 10 | "image_height" integer, 11 | "creator_id" uuid, 12 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 13 | "scraped" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 14 | CONSTRAINT "post_link_url_unique" UNIQUE("url"), 15 | CONSTRAINT "post_link_image_type_check" CHECK ( 16 | CASE 17 | WHEN "post_link"."image_type" IS NULL THEN true 18 | ELSE "post_link"."image_type" ~ '^image/' AND 19 | "post_link"."image_url" IS NOT NULL 20 | END 21 | ), 22 | CONSTRAINT "post_link_image_width_height_check" CHECK ( 23 | CASE 24 | WHEN "post_link"."image_width" IS NOT NULL 25 | THEN "post_link"."image_url" IS NOT NULL AND 26 | "post_link"."image_height" IS NOT NULL AND 27 | "post_link"."image_width" > 0 AND 28 | "post_link"."image_height" > 0 29 | WHEN "post_link"."image_height" IS NOT NULL 30 | THEN "post_link"."image_url" IS NOT NULL AND 31 | "post_link"."image_width" IS NOT NULL AND 32 | "post_link"."image_width" > 0 AND 33 | "post_link"."image_height" > 0 34 | ELSE true 35 | END 36 | ) 37 | ); 38 | --> statement-breakpoint 39 | ALTER TABLE "post" ADD COLUMN "link_id" uuid;--> statement-breakpoint 40 | ALTER TABLE "post" ADD COLUMN "link_url" text;--> statement-breakpoint 41 | DO $$ BEGIN 42 | ALTER TABLE "post_link" ADD CONSTRAINT "post_link_creator_id_actor_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."actor"("id") ON DELETE set null ON UPDATE no action; 43 | EXCEPTION 44 | WHEN duplicate_object THEN null; 45 | END $$; 46 | --> statement-breakpoint 47 | DO $$ BEGIN 48 | ALTER TABLE "post" ADD CONSTRAINT "post_link_id_post_link_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."post_link"("id") ON DELETE restrict ON UPDATE no action; 49 | EXCEPTION 50 | WHEN duplicate_object THEN null; 51 | END $$; 52 | --> statement-breakpoint 53 | ALTER TABLE "post" ADD CONSTRAINT "post_link_id_check" CHECK (("post"."link_id" IS NULL) = ("post"."link_url" IS NULL)); -------------------------------------------------------------------------------- /drizzle/0038_post_link.site_name.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post_link" ADD COLUMN "site_name" text; -------------------------------------------------------------------------------- /drizzle/0039_post_link.image_alt.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post_link" ADD COLUMN "image_alt" text;--> statement-breakpoint 2 | ALTER TABLE "post_link" ADD CONSTRAINT "post_link_image_alt_check" CHECK ("post_link"."image_alt" IS NULL OR "post_link"."image_url" IS NOT NULL); -------------------------------------------------------------------------------- /drizzle/0040_post_link.author.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post_link" ADD COLUMN "author" text; -------------------------------------------------------------------------------- /drizzle/0041_post_link_url_check.sql: -------------------------------------------------------------------------------- 1 | UPDATE "post" 2 | SET "link_id" = NULL, "link_url" = NULL 3 | WHERE "post"."link_id" NOT IN ( 4 | SELECT "post_link"."id" 5 | FROM "post_link" 6 | WHERE "post_link"."url" ~ '^https?://' 7 | AND ("post_link"."image_url" IS NULL OR "post_link"."image_url" ~ '^https?://') 8 | );--> statement-breakpoint 9 | DELETE FROM "post_link" 10 | WHERE NOT ("post_link"."url" ~ '^https?://') 11 | OR "post_link"."image_url" IS NOT NULL 12 | AND NOT ("post_link"."image_url" ~ '^https?://');--> statement-breakpoint 13 | ALTER TABLE "post_link" ADD CONSTRAINT "post_link_url_check" CHECK ("post_link"."url" ~ '^https?://');--> statement-breakpoint 14 | ALTER TABLE "post_link" ADD CONSTRAINT "post_link_image_url_check" CHECK ("post_link"."image_url" ~ '^https?://'); 15 | -------------------------------------------------------------------------------- /drizzle/0042_account.left_invitations.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "left_invitations" smallint;--> statement-breakpoint 2 | UPDATE "account" SET "left_invitations" = 0 WHERE "left_invitations" IS NULL;--> statement-breakpoint 3 | ALTER TABLE "account" ALTER COLUMN "left_invitations" SET NOT NULL; 4 | -------------------------------------------------------------------------------- /drizzle/0043_account.inviter_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "inviter_id" uuid;--> statement-breakpoint 2 | DO $$ BEGIN 3 | ALTER TABLE "account" ADD CONSTRAINT "account_inviter_id_account_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."account"("id") ON DELETE set null ON UPDATE no action; 4 | EXCEPTION 5 | WHEN duplicate_object THEN null; 6 | END $$; 7 | -------------------------------------------------------------------------------- /drizzle/0044_post.quoted_post_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "allowed_email" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint 2 | DROP TABLE "allowed_email" CASCADE;--> statement-breakpoint 3 | ALTER TABLE "post" DROP CONSTRAINT "post_reply_target_id_post_id_fk"; 4 | --> statement-breakpoint 5 | ALTER TABLE "post" ADD COLUMN "quoted_post_id" uuid;--> statement-breakpoint 6 | DO $$ BEGIN 7 | ALTER TABLE "post" ADD CONSTRAINT "post_quoted_post_id_post_id_fk" FOREIGN KEY ("quoted_post_id") REFERENCES "public"."post"("id") ON DELETE set null ON UPDATE no action; 8 | EXCEPTION 9 | WHEN duplicate_object THEN null; 10 | END $$; 11 | --> statement-breakpoint 12 | DO $$ BEGIN 13 | ALTER TABLE "post" ADD CONSTRAINT "post_reply_target_id_post_id_fk" FOREIGN KEY ("reply_target_id") REFERENCES "public"."post"("id") ON DELETE set null ON UPDATE no action; 14 | EXCEPTION 15 | WHEN duplicate_object THEN null; 16 | END $$; 17 | -------------------------------------------------------------------------------- /drizzle/0045_actor.handle.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ADD COLUMN "handle_host" text;--> statement-breakpoint 2 | UPDATE "actor" SET "handle_host" = "instance_host" WHERE "handle_host" IS NULL;--> statement-breakpoint 3 | ALTER TABLE "actor" ALTER COLUMN "handle_host" SET NOT NULL;--> statement-breakpoint 4 | ALTER TABLE "actor" ADD COLUMN "handle" text GENERATED ALWAYS AS ('@' || "actor"."username" || '@' || "actor"."handle_host") STORED; 5 | -------------------------------------------------------------------------------- /drizzle/0046_not-null-actor.handle.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ALTER COLUMN "handle" SET NOT NULL; -------------------------------------------------------------------------------- /drizzle/0047_account.hide_from_invitation_tree.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "hide_from_invitation_tree" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0048_indices.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "following_follower_id_index" ON "following" USING btree ("follower_id");--> statement-breakpoint 2 | CREATE INDEX "mention_actor_id_index" ON "mention" USING btree ("actor_id");--> statement-breakpoint 3 | CREATE INDEX "post_link_creator_id_index" ON "post_link" USING btree ("creator_id");--> statement-breakpoint 4 | CREATE INDEX "idx_post_visibility_published" ON "post" USING btree ("visibility","published" desc);--> statement-breakpoint 5 | CREATE INDEX "idx_post_actor_id_published" ON "post" USING btree ("actor_id","published" desc);--> statement-breakpoint 6 | CREATE INDEX "post_reply_target_id_index" ON "post" USING btree ("reply_target_id"); -------------------------------------------------------------------------------- /drizzle/0049_timeline_item.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "timeline_item" ( 2 | "account_id" uuid NOT NULL, 3 | "post_id" uuid NOT NULL, 4 | "last_sharer_id" uuid, 5 | "sharers_count" integer DEFAULT 0 NOT NULL, 6 | "added" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | CONSTRAINT "timeline_item_account_id_post_id_pk" PRIMARY KEY("account_id","post_id") 8 | ); 9 | --> statement-breakpoint 10 | ALTER TABLE "timeline_item" ADD CONSTRAINT "timeline_item_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 11 | ALTER TABLE "timeline_item" ADD CONSTRAINT "timeline_item_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 12 | ALTER TABLE "timeline_item" ADD CONSTRAINT "timeline_item_last_sharer_id_actor_id_fk" FOREIGN KEY ("last_sharer_id") REFERENCES "public"."actor"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint 13 | CREATE INDEX "idx_timeline_item_account_id_added" ON "timeline_item" USING btree ("account_id","added" desc); -------------------------------------------------------------------------------- /drizzle/0050_fill timeline_item.sql: -------------------------------------------------------------------------------- 1 | -- Custom SQL migration file, put your code below! -- 2 | -------------------------------------------------------------------------------- /drizzle/0051_timeline_item.original_author_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "timeline_item" ADD COLUMN "original_author_id" uuid;--> statement-breakpoint 2 | ALTER TABLE "timeline_item" ADD CONSTRAINT "timeline_item_original_author_id_actor_id_fk" FOREIGN KEY ("original_author_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action; 3 | -------------------------------------------------------------------------------- /drizzle/0053_post.quotes_count.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" ADD COLUMN "quotes_count" integer DEFAULT 0 NOT NULL; 2 | 3 | -- Update quotes_count for existing posts 4 | WITH quote_counts AS ( 5 | SELECT 6 | quoted_post_id, 7 | COUNT(*) AS count 8 | FROM "post" 9 | WHERE quoted_post_id IS NOT NULL 10 | GROUP BY quoted_post_id 11 | ) 12 | UPDATE "post" 13 | SET quotes_count = quote_counts.count 14 | FROM quote_counts 15 | WHERE "post".id = quote_counts.quoted_post_id; -------------------------------------------------------------------------------- /drizzle/0054_notifications.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."notification_type" AS ENUM('follow', 'mention', 'reply', 'share', 'quote');--> statement-breakpoint 2 | CREATE TABLE "notification" ( 3 | "id" uuid PRIMARY KEY NOT NULL, 4 | "account_id" uuid NOT NULL, 5 | "type" "notification_type" NOT NULL, 6 | "post_id" uuid, 7 | "actor_ids" uuid[] DEFAULT (ARRAY[]::uuid[]) NOT NULL, 8 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 9 | CONSTRAINT "notification_account_id_type_post_id_unique" UNIQUE("account_id","type","post_id"), 10 | CONSTRAINT "notification_post_id_check" CHECK ( 11 | CASE "notification"."type" 12 | WHEN 'follow' THEN "notification"."post_id" IS NULL 13 | ELSE "notification"."post_id" IS NOT NULL 14 | END 15 | ) 16 | ); 17 | --> statement-breakpoint 18 | ALTER TABLE "notification" ADD CONSTRAINT "notification_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 19 | ALTER TABLE "notification" ADD CONSTRAINT "notification_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 20 | CREATE INDEX "idx_notification_account_id_id" ON "notification" USING btree ("account_id","id" desc); -------------------------------------------------------------------------------- /drizzle/0056_idx_notification_account_id_created.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX "idx_notification_account_id_id";--> statement-breakpoint 2 | CREATE INDEX "idx_notification_account_id_created" ON "notification" USING btree ("account_id","created" desc); -------------------------------------------------------------------------------- /drizzle/0057_account.notification_read.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "notification_read" timestamp with time zone; -------------------------------------------------------------------------------- /drizzle/0058_account.hide_foreign_languages.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "hide_foreign_languages" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0060_add-react-to-notification_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."notification_type" ADD VALUE 'react'; 2 | COMMIT; 3 | -------------------------------------------------------------------------------- /drizzle/0061_add-react-to-notification_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "notification" DROP CONSTRAINT "notification_account_id_type_post_id_unique";--> statement-breakpoint 2 | ALTER TABLE "notification" ADD COLUMN "emoji" text;--> statement-breakpoint 3 | ALTER TABLE "notification" ADD COLUMN "custom_emoji_id" uuid;--> statement-breakpoint 4 | ALTER TABLE "notification" ADD CONSTRAINT "notification_custom_emoji_id_custom_emoji_id_fk" FOREIGN KEY ("custom_emoji_id") REFERENCES "public"."custom_emoji"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 5 | CREATE UNIQUE INDEX "notification_account_id_actor_ids_index" ON "notification" USING btree ("account_id","actor_ids") WHERE "notification"."type" = 'follow';--> statement-breakpoint 6 | CREATE UNIQUE INDEX "notification_account_id_post_id_index" ON "notification" USING btree ("account_id","post_id") WHERE "notification"."type" NOT IN ('follow', 'react');--> statement-breakpoint 7 | CREATE UNIQUE INDEX "notification_account_id_post_id_emoji_index" ON "notification" USING btree ("account_id","post_id","emoji") WHERE "notification"."type" = 'react' AND "notification"."custom_emoji_id" IS NULL;--> statement-breakpoint 8 | CREATE UNIQUE INDEX "notification_account_id_post_id_custom_emoji_id_index" ON "notification" USING btree ("account_id","post_id","custom_emoji_id") WHERE "notification"."type" = 'react' AND "notification"."emoji" IS NULL;--> statement-breakpoint 9 | ALTER TABLE "notification" ADD CONSTRAINT "notification_emoji_check" CHECK ( 10 | CASE "notification"."type" 11 | WHEN 'react' 12 | THEN "notification"."emoji" IS NOT NULL AND "notification"."custom_emoji_id" IS NULL 13 | OR "notification"."emoji" IS NULL AND "notification"."custom_emoji_id" IS NOT NULL 14 | ELSE "notification"."emoji" IS NULL AND "notification"."custom_emoji_id" IS NULL 15 | END 16 | ); -------------------------------------------------------------------------------- /drizzle/0062_post.reactions_count.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION json_sum_object_Values(input_jsonb jsonb) 2 | RETURNS integer 3 | LANGUAGE sql 4 | IMMUTABLE 5 | PARALLEL SAFE 6 | AS $$ 7 | SELECT coalesce(sum(value::integer), 0) 8 | FROM jsonb_each(input_jsonb) 9 | WHERE jsonb_typeof(value) = 'number'; 10 | $$;--> statement-breakpoint 11 | ALTER TABLE "post" ADD COLUMN "reactionsCount" integer GENERATED ALWAYS AS (json_sum_object_values("post"."reactions_counts")) STORED NOT NULL;--> statement-breakpoint 12 | ALTER TABLE "post" DROP COLUMN "likes_count";--> statement-breakpoint 13 | ALTER TABLE "post" ADD CONSTRAINT "post_reactions_acounts_check" CHECK ("post"."reactions_counts" IS JSON OBJECT); 14 | -------------------------------------------------------------------------------- /drizzle/0063_post.reactions_count.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" RENAME COLUMN "reactionsCount" TO "reactions_count"; -------------------------------------------------------------------------------- /drizzle/0064_blocking.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "blocking" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "iri" text NOT NULL, 4 | "blocker_id" uuid NOT NULL, 5 | "blockee_id" uuid NOT NULL, 6 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | CONSTRAINT "blocking_iri_unique" UNIQUE("iri"), 8 | CONSTRAINT "blocking_blocker_id_blockee_id_unique" UNIQUE("blocker_id","blockee_id"), 9 | CONSTRAINT "blocking_blocker_blockee_check" CHECK ("blocking"."blocker_id" != "blocking"."blockee_id") 10 | ); 11 | --> statement-breakpoint 12 | ALTER TABLE "blocking" ADD CONSTRAINT "blocking_blocker_id_actor_id_fk" FOREIGN KEY ("blocker_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 13 | ALTER TABLE "blocking" ADD CONSTRAINT "blocking_blockee_id_actor_id_fk" FOREIGN KEY ("blockee_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /drizzle/0065_normalize-tags.sql: -------------------------------------------------------------------------------- 1 | -- Normalize tags in the post table by converting all tag names to lowercase 2 | UPDATE post 3 | SET tags = ( 4 | SELECT jsonb_object_agg(lower(key), value) 5 | FROM jsonb_each_text(tags) 6 | ) 7 | WHERE tags != '{}'; 8 | -------------------------------------------------------------------------------- /drizzle/0066_actor.tags.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "actor" ADD COLUMN "tags" jsonb DEFAULT '{}'::jsonb NOT NULL; -------------------------------------------------------------------------------- /drizzle/0067_passkey.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."passkey_device_type" AS ENUM('singleDevice', 'multiDevice');--> statement-breakpoint 2 | CREATE TYPE "public"."passkey_transport" AS ENUM('ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb');--> statement-breakpoint 3 | CREATE TABLE "passkey" ( 4 | "id" text PRIMARY KEY NOT NULL, 5 | "account_id" uuid NOT NULL, 6 | "public_key" "bytea" NOT NULL, 7 | "webauthn_user_id" text NOT NULL, 8 | "counter" bigint NOT NULL, 9 | "device_type" "passkey_device_type" NOT NULL, 10 | "backed_up" boolean NOT NULL, 11 | "transports" "passkey_transport"[], 12 | CONSTRAINT "passkey_account_id_webauthn_user_id_unique" UNIQUE("account_id","webauthn_user_id") 13 | ); 14 | --> statement-breakpoint 15 | ALTER TABLE "passkey" ADD CONSTRAINT "passkey_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 16 | CREATE INDEX "passkey_account_id_index" ON "passkey" USING btree ("account_id");--> statement-breakpoint 17 | CREATE INDEX "passkey_webauthn_user_id_index" ON "passkey" USING btree ("webauthn_user_id"); -------------------------------------------------------------------------------- /drizzle/0068_passkey.name.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "passkey" ADD COLUMN "name" text NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "passkey" ADD CONSTRAINT "passkey_name_check" CHECK ("passkey"."name" !~ '^[[:space:]]*$'); -------------------------------------------------------------------------------- /drizzle/0069_passkey.created.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "passkey" ADD COLUMN "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL; -------------------------------------------------------------------------------- /drizzle/0070_passkey.last_used.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "passkey" ADD COLUMN "last_used" timestamp with time zone; -------------------------------------------------------------------------------- /drizzle/0071_normalize_invalid_timestamps.sql: -------------------------------------------------------------------------------- 1 | UPDATE "actor" 2 | SET "published" = NULL 3 | WHERE "actor"."published" < '1970-01-01 00:00:00'; 4 | UPDATE "actor" 5 | SET "updated" = '1970-01-01 00:00:00' 6 | WHERE "actor"."updated" < '1970-01-01 00:00:00'; 7 | -------------------------------------------------------------------------------- /drizzle/0072_post_medium.thumbnail_key.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."post_medium_type" ADD VALUE 'video/mp4';--> statement-breakpoint 2 | ALTER TYPE "public"."post_medium_type" ADD VALUE 'video/webm';--> statement-breakpoint 3 | ALTER TABLE "post_medium" ADD COLUMN "thumbnail_key" text;--> statement-breakpoint 4 | ALTER TABLE "post_medium" ADD CONSTRAINT "post_medium_thumbnail_key_unique" UNIQUE("thumbnail_key"); -------------------------------------------------------------------------------- /drizzle/0073_add-video-quicktime-to-post_medium_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."post_medium_type" ADD VALUE 'video/quicktime'; -------------------------------------------------------------------------------- /drizzle/0074_poll.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "poll_option" ( 2 | "post_id" uuid NOT NULL, 3 | "index" smallint NOT NULL, 4 | "title" text NOT NULL, 5 | "votes_count" integer DEFAULT 0 NOT NULL, 6 | CONSTRAINT "poll_option_post_id_index_pk" PRIMARY KEY("post_id","index"), 7 | CONSTRAINT "poll_option_index_check" CHECK ("poll_option"."index" >= 0), 8 | CONSTRAINT "poll_option_votes_count_check" CHECK ("poll_option"."votes_count" >= 0) 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE "poll" ( 12 | "post_id" uuid PRIMARY KEY NOT NULL, 13 | "multiple" boolean NOT NULL, 14 | "voters_count" integer DEFAULT 0 NOT NULL, 15 | "ends" timestamp with time zone NOT NULL, 16 | CONSTRAINT "poll_voters_count_check" CHECK ("poll"."voters_count" >= 0) 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE "poll_vote" ( 20 | "post_id" uuid NOT NULL, 21 | "option_index" smallint NOT NULL, 22 | "actor_id" uuid NOT NULL, 23 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 24 | CONSTRAINT "poll_vote_post_id_option_index_actor_id_pk" PRIMARY KEY("post_id","option_index","actor_id") 25 | ); 26 | --> statement-breakpoint 27 | ALTER TABLE "poll_option" ADD CONSTRAINT "poll_option_post_id_poll_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."poll"("post_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 28 | ALTER TABLE "poll" ADD CONSTRAINT "poll_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 29 | ALTER TABLE "poll_vote" ADD CONSTRAINT "poll_vote_post_id_poll_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."poll"("post_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 30 | ALTER TABLE "poll_vote" ADD CONSTRAINT "poll_vote_actor_id_actor_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 31 | ALTER TABLE "poll_vote" ADD CONSTRAINT "poll_vote_post_id_option_index_poll_option_post_id_index_fk" FOREIGN KEY ("post_id","option_index") REFERENCES "public"."poll_option"("post_id","index") ON DELETE no action ON UPDATE no action; -------------------------------------------------------------------------------- /drizzle/0075_unique-constraint-poll_option.title.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "poll_option" ADD CONSTRAINT "poll_option_post_id_title_unique" UNIQUE("post_id","title"); -------------------------------------------------------------------------------- /drizzle/0076_pin.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "post" ADD CONSTRAINT "post_id_actor_id_unique" UNIQUE("id","actor_id");--> statement-breakpoint 2 | CREATE TABLE "pin" ( 3 | "post_id" uuid NOT NULL, 4 | "actor_id" uuid NOT NULL, 5 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 6 | CONSTRAINT "pin_post_id_actor_id_pk" PRIMARY KEY("post_id","actor_id") 7 | ); 8 | --> statement-breakpoint 9 | ALTER TABLE "pin" ADD CONSTRAINT "pin_actor_id_actor_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 10 | ALTER TABLE "pin" ADD CONSTRAINT "pin_post_id_actor_id_post_id_actor_id_fk" FOREIGN KEY ("post_id","actor_id") REFERENCES "public"."post"("id","actor_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 11 | CREATE INDEX "pin_actor_id_index" ON "pin" USING btree ("actor_id"); 12 | -------------------------------------------------------------------------------- /drizzle/0078_article_content_original_language_check.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_content" ADD CONSTRAINT "article_content_original_language_check" CHECK (( 2 | "article_content"."translator_id" IS NULL AND 3 | "article_content"."translation_requester_id" IS NULL 4 | ) = ("article_content"."original_language" IS NULL)); 5 | -------------------------------------------------------------------------------- /drizzle/0079_article_content.summary.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_content" DROP CONSTRAINT "article_content_original_language_check";--> statement-breakpoint 2 | ALTER TABLE "article_content" ADD COLUMN "summary" text;--> statement-breakpoint 3 | ALTER TABLE "article_content" ADD COLUMN "summary_started" timestamp with time zone;--> statement-breakpoint 4 | ALTER TABLE "article_content" ADD CONSTRAINT "article_content_original_language_check" CHECK (( 5 | "article_content"."translator_id" IS NULL AND 6 | "article_content"."translation_requester_id" IS NULL 7 | ) = ("article_content"."original_language" IS NULL)); -------------------------------------------------------------------------------- /drizzle/0080_article_content.being_translated.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article_content" ADD COLUMN "being_translated" boolean DEFAULT false NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "article_source" ADD COLUMN "allow_llm_translation" boolean DEFAULT false NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "article_content" ADD CONSTRAINT "article_content_being_translated_check" CHECK (NOT "article_content"."being_translated" OR ("article_content"."original_language" IS NOT NULL)); -------------------------------------------------------------------------------- /drizzle/0081_account.prefer_ai_summary.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account" ADD COLUMN "prefer_ai_summary" boolean DEFAULT true NOT NULL; -------------------------------------------------------------------------------- /drizzle/0082_invitation_link.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "invitation_link" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "inviter_id" uuid NOT NULL, 4 | "invitations_left" smallint NOT NULL, 5 | "message" text, 6 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | "expires" timestamp with time zone 8 | ); 9 | --> statement-breakpoint 10 | ALTER TABLE "invitation_link" ADD CONSTRAINT "invitation_link_inviter_id_account_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."account"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /drizzle/0083_index-lower-email.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "idx_account_email_lower_email" ON "account_email" USING btree (lower("email")); -------------------------------------------------------------------------------- /federation/builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createFederationBuilder, 3 | type FederationBuilder, 4 | } from "@fedify/fedify"; 5 | import type { ContextData } from "@hackerspub/models/context"; 6 | 7 | export const builder: FederationBuilder = createFederationBuilder< 8 | ContextData 9 | >(); 10 | -------------------------------------------------------------------------------- /federation/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerspub/federation", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts", 6 | "./actor": "./actor.ts", 7 | "./builder": "./builder.ts", 8 | "./collections": "./collections.ts", 9 | "./inbox": "./inbox/mod.ts", 10 | "./nodeinfo": "./nodeinfo.ts", 11 | "./objects": "./objects.ts" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /federation/mod.ts: -------------------------------------------------------------------------------- 1 | import "./actor.ts"; 2 | import "./collections.ts"; 3 | import "./inbox/mod.ts"; 4 | import "./nodeinfo.ts"; 5 | import "./objects.ts"; 6 | export { builder } from "./builder.ts"; 7 | -------------------------------------------------------------------------------- /federation/nodeinfo.ts: -------------------------------------------------------------------------------- 1 | import { parseSemVer } from "@fedify/fedify"; 2 | import { count, countDistinct, gt, sql } from "drizzle-orm"; 3 | import { 4 | accountTable, 5 | articleSourceTable, 6 | noteSourceTable, 7 | } from "@hackerspub/models/schema"; 8 | import { builder } from "./builder.ts"; 9 | import metadata from "./deno.json" with { type: "json" }; 10 | 11 | builder.setNodeInfoDispatcher("/nodeinfo/2.1", async (ctx) => { 12 | const { db } = ctx.data; 13 | const [{ total }] = await db.select({ total: count() }).from(accountTable); 14 | const [{ activeMonth }] = await db.select({ 15 | activeMonth: countDistinct(articleSourceTable.accountId), 16 | }).from(articleSourceTable).where( 17 | gt( 18 | articleSourceTable.published, 19 | sql`CURRENT_TIMESTAMP - INTERVAL '1 month'`, 20 | ), 21 | ); 22 | const [{ activeHalfyear }] = await db.select({ 23 | activeHalfyear: countDistinct(articleSourceTable.accountId), 24 | }).from(articleSourceTable).where( 25 | gt( 26 | articleSourceTable.published, 27 | sql`CURRENT_TIMESTAMP - INTERVAL '6 months'`, 28 | ), 29 | ); 30 | const [{ localArticles }] = await db.select({ localArticles: count() }) 31 | .from(articleSourceTable); 32 | const [{ localNotes }] = await db.select({ localNotes: count() }) 33 | .from(noteSourceTable); 34 | return { 35 | software: { 36 | name: "hackerspub", 37 | version: parseSemVer(metadata.version), 38 | homepage: new URL("https://hackers.pub/"), 39 | repository: new URL("https://github.com/hackers-pub/hackerspub"), 40 | }, 41 | protocols: ["activitypub"], 42 | services: { 43 | inbound: [], 44 | outbound: ["atom1.0"], 45 | }, 46 | usage: { 47 | users: { 48 | total, 49 | activeMonth, 50 | activeHalfyear, 51 | }, 52 | localComments: 0, // TODO 53 | localPosts: localArticles + localNotes, 54 | }, 55 | }; 56 | }); 57 | -------------------------------------------------------------------------------- /graphql/ai.ts: -------------------------------------------------------------------------------- 1 | import { anthropic } from "@ai-sdk/anthropic"; 2 | import { google } from "@ai-sdk/google"; 3 | 4 | export const summarizer = google("gemini-2.0-flash"); 5 | export const translator = anthropic("claude-3-7-sonnet-20250219"); 6 | -------------------------------------------------------------------------------- /graphql/db.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from "@hackerspub/models/db"; 2 | import { DatabaseLogger } from "@hackerspub/models/dblogger"; 3 | import { relations } from "@hackerspub/models/relations"; 4 | import * as schema from "@hackerspub/models/schema"; 5 | import { getLogger } from "@logtape/logtape"; 6 | import { drizzle } from "drizzle-orm/postgres-js"; 7 | import postgresJs from "postgres"; 8 | import "./logging.ts"; 9 | 10 | const DATABASE_URL = Deno.env.get("DATABASE_URL"); 11 | if (DATABASE_URL == null) { 12 | throw new Error("Missing DATABASE_URL environment variable."); 13 | } 14 | 15 | export const postgres = postgresJs(DATABASE_URL); 16 | export const db: Database = drizzle({ 17 | schema, 18 | relations, 19 | client: postgres, 20 | logger: new DatabaseLogger(), 21 | }); 22 | getLogger(["hackerspub", "db"]) 23 | .debug("The driver is ready: {driver}", { driver: db.constructor }); 24 | -------------------------------------------------------------------------------- /graphql/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerspub/graphql", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | }, 7 | "tasks": { 8 | "codegen": "deno run -A --env-file=../.env mod.ts", 9 | "dev": "deno run -A --env-file=../.env main.ts" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /graphql/federation.ts: -------------------------------------------------------------------------------- 1 | import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres"; 2 | import { RedisKvStore } from "@fedify/redis"; 3 | import { builder } from "@hackerspub/federation"; 4 | import { getLogger } from "@logtape/logtape"; 5 | import { Redis } from "ioredis"; 6 | import { postgres } from "./db.ts"; 7 | import metadata from "./deno.json" with { type: "json" }; 8 | import { kvUrl } from "./kv.ts"; 9 | 10 | const logger = getLogger(["hackerspub", "federation"]); 11 | 12 | const origin = Deno.env.get("ORIGIN"); 13 | if (origin == null) { 14 | throw new Error("Missing ORIGIN environment variable."); 15 | } else if (!origin.startsWith("https://") && !origin.startsWith("http://")) { 16 | throw new Error("ORIGIN must start with http:// or https://"); 17 | } 18 | export const ORIGIN = origin; 19 | 20 | const kv = kvUrl.protocol === "redis:" 21 | ? new RedisKvStore( 22 | new Redis(kvUrl.href, { 23 | family: kvUrl.hostname.endsWith(".upstash.io") ? 6 : 4, 24 | }), 25 | ) 26 | : new PostgresKvStore(postgres); 27 | logger.debug("KV store initialized: {kv}", { kv }); 28 | 29 | const queue = new PostgresMessageQueue(postgres); 30 | logger.debug("Message queue initialized: {queue}", { queue }); 31 | 32 | export const federation = await builder.build({ 33 | kv, 34 | queue, 35 | origin: ORIGIN, 36 | userAgent: { 37 | software: `HackersPub/${metadata.version}`, 38 | url: new URL(ORIGIN), 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /graphql/kv.ts: -------------------------------------------------------------------------------- 1 | import KeyvRedis from "@keyv/redis"; 2 | import { KeyvFile } from "keyv-file"; 3 | import Keyv from "keyv"; 4 | 5 | const KV_URL = Deno.env.get("KV_URL"); 6 | if (KV_URL == null) { 7 | throw new Error("Missing KV_URL environment variable."); 8 | } else if (!URL.canParse(KV_URL)) { 9 | throw new Error("Invalid KV_URL environment variable; must be a valid URL."); 10 | } 11 | 12 | export const kvUrl = new URL(KV_URL); 13 | if (kvUrl.protocol !== "file:" && kvUrl.protocol !== "redis:") { 14 | throw new Error( 15 | "Invalid KV_URL environment variable; must start with file: or redis:", 16 | ); 17 | } 18 | 19 | export const kvAdaptor = kvUrl.protocol === "file:" 20 | ? new KeyvFile({ filename: kvUrl.pathname }) 21 | : new KeyvRedis(KV_URL); 22 | export const kv = new Keyv(kvAdaptor); 23 | -------------------------------------------------------------------------------- /graphql/logging.ts: -------------------------------------------------------------------------------- 1 | import { ansiColorFormatter, configure, getStreamSink } from "@logtape/logtape"; 2 | import { AsyncLocalStorage } from "node:async_hooks"; 3 | 4 | const LOG_QUERY = Deno.env.get("LOG_QUERY")?.toLowerCase() === "true"; 5 | 6 | await configure({ 7 | contextLocalStorage: new AsyncLocalStorage(), 8 | sinks: { 9 | console: getStreamSink(Deno.stderr.writable, { 10 | formatter: ansiColorFormatter, 11 | }), 12 | }, 13 | loggers: [ 14 | { 15 | category: "hackerspub", 16 | lowestLevel: "debug", 17 | sinks: ["console"], 18 | }, 19 | { 20 | category: "drizzle-orm", 21 | lowestLevel: LOG_QUERY ? "debug" : "info", 22 | sinks: ["console"], 23 | }, 24 | { category: "fedify", lowestLevel: "info", sinks: ["console"] }, 25 | { 26 | category: ["logtape", "meta"], 27 | lowestLevel: "warning", 28 | sinks: ["console"], 29 | }, 30 | ], 31 | }); 32 | -------------------------------------------------------------------------------- /graphql/main.ts: -------------------------------------------------------------------------------- 1 | import { getCookies } from "@std/http/cookie"; 2 | import { getSession } from "@hackerspub/models/session"; 3 | import { validateUuid } from "@hackerspub/models/uuid"; 4 | import { createYogaServer } from "./mod.ts"; 5 | import { db } from "./db.ts"; 6 | import { kv } from "./kv.ts"; 7 | import { drive } from "./drive.ts"; 8 | import { federation } from "./federation.ts"; 9 | import * as models from "./ai.ts"; 10 | 11 | const yogaServer = createYogaServer(); 12 | 13 | Deno.serve({ port: 8080 }, (req) => { 14 | const cookies = getCookies(req.headers); 15 | const session = validateUuid(cookies.session) 16 | ? getSession(kv, cookies.session) 17 | : undefined; 18 | 19 | const disk = drive.use(); 20 | return yogaServer.fetch(req, { 21 | db, 22 | kv, 23 | disk, 24 | session, 25 | fedCtx: federation.createContext(req, { db, kv, disk, models }), 26 | moderator: false, 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /graphql/mod.ts: -------------------------------------------------------------------------------- 1 | import { type GraphQLSchema, printSchema } from "graphql"; 2 | import path from "node:path"; 3 | import "./account.ts"; 4 | import "./actor.ts"; 5 | import { builder } from "./builder.ts"; 6 | import "./poll.ts"; 7 | import "./post.ts"; 8 | import "./reactable.ts"; 9 | export type { Context } from "./builder.ts"; 10 | export { createYogaServer } from "./server.ts"; 11 | 12 | export const schema: GraphQLSchema = builder.toSchema(); 13 | 14 | void Deno.writeTextFile( 15 | path.join(import.meta.dirname ?? "", "schema.graphql"), 16 | printSchema(schema), 17 | ); 18 | -------------------------------------------------------------------------------- /graphql/server.ts: -------------------------------------------------------------------------------- 1 | import { execute } from "graphql"; 2 | import { 3 | createYoga, 4 | type Plugin as EnvelopPlugin, 5 | type YogaServerInstance, 6 | } from "graphql-yoga"; 7 | import type { Context } from "./builder.ts"; 8 | import { schema as graphqlSchema } from "./mod.ts"; 9 | 10 | export function createYogaServer(): YogaServerInstance { 11 | return createYoga({ 12 | schema: graphqlSchema, 13 | context: (ctx) => ctx, 14 | plugins: [{ 15 | onExecute: ({ setExecuteFn, context }) => { 16 | const isNoPropagate = 17 | new URL(context.request.url).searchParams.get("no-propagate") === 18 | "true" || 19 | context.request.headers.get("x-no-propagate") === "true"; 20 | setExecuteFn((args) => 21 | execute({ 22 | ...args, 23 | onError: isNoPropagate ? "NO_PROPAGATE" : "PROPAGATE", 24 | }) 25 | ); 26 | }, 27 | } as EnvelopPlugin], 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | deno = "2.3.5" 3 | -------------------------------------------------------------------------------- /models/avatar.ts: -------------------------------------------------------------------------------- 1 | export function getAvatarUrl(actor: { avatarUrl: string | null }): string { 2 | return actor.avatarUrl ?? "https://gravatar.com/avatar/?d=mp&s=128"; 3 | } 4 | -------------------------------------------------------------------------------- /models/context.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageModelV1 } from "ai"; 2 | import type { Disk } from "flydrive"; 3 | import type Keyv from "keyv"; 4 | import type { Database } from "./db.ts"; 5 | 6 | export interface Models { 7 | translator: LanguageModelV1; 8 | summarizer: LanguageModelV1; 9 | } 10 | 11 | export interface ContextData { 12 | db: D; 13 | kv: Keyv; 14 | disk: Disk; 15 | models: Models; 16 | } 17 | -------------------------------------------------------------------------------- /models/date.ts: -------------------------------------------------------------------------------- 1 | export function toDate(instant: Temporal.Instant): Date | undefined; 2 | export function toDate(instant: Temporal.Instant | null): Date | null; 3 | export function toDate(instant?: Temporal.Instant): Date | undefined; 4 | export function toDate( 5 | instant?: Temporal.Instant | null, 6 | ): Date | undefined | null { 7 | if (instant == null) return instant; 8 | if ( 9 | Temporal.Instant.compare( 10 | instant, 11 | Temporal.Instant.fromEpochMilliseconds(0), 12 | ) < 0 13 | ) { 14 | return undefined; 15 | } 16 | return new Date(instant.toString()); 17 | } 18 | -------------------------------------------------------------------------------- /models/db.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExtractTablesWithRelations, 3 | RelationsFilter as RelationsFilterImpl, 4 | } from "drizzle-orm"; 5 | import type { PgDatabase, PgTransaction } from "drizzle-orm/pg-core"; 6 | import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; 7 | import type { relations } from "./relations.ts"; 8 | import type * as schema from "./schema.ts"; 9 | 10 | export type Database = PgDatabase< 11 | PostgresJsQueryResultHKT, 12 | typeof schema, 13 | typeof relations 14 | >; 15 | 16 | export type Transaction = PgTransaction< 17 | PostgresJsQueryResultHKT, 18 | typeof schema, 19 | typeof relations 20 | >; 21 | 22 | export type RelationsFilter< 23 | T extends keyof ExtractTablesWithRelations, 24 | > = RelationsFilterImpl< 25 | ExtractTablesWithRelations[T], 26 | ExtractTablesWithRelations 27 | >; 28 | -------------------------------------------------------------------------------- /models/dblogger.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@logtape/logtape"; 2 | import type { Logger } from "drizzle-orm/logger"; 3 | 4 | export class DatabaseLogger implements Logger { 5 | readonly logger = getLogger("drizzle-orm"); 6 | 7 | logQuery(query: string, params: unknown[]): void { 8 | const stringifiedParams = params.map(DatabaseLogger.serialize); 9 | const formattedQuery = query.replace(/\$(\d+)/g, (m) => { 10 | const index = Number.parseInt(m.slice(1), 10); 11 | return stringifiedParams[index - 1]; 12 | }); 13 | this.logger.debug("Query: {formattedQuery}", { 14 | formattedQuery, 15 | query, 16 | params, 17 | }); 18 | } 19 | 20 | static serialize(p: unknown): string { 21 | if (typeof p === "undefined" || p === null) return "NULL"; 22 | if (typeof p === "string") return DatabaseLogger.stringLiteral(p); 23 | if (typeof p === "number" || typeof p === "bigint") return p.toString(); 24 | if (typeof p === "boolean") return p ? "'t'" : "'f'"; 25 | if (p instanceof Date) return DatabaseLogger.stringLiteral(p.toISOString()); 26 | if (Array.isArray(p)) { 27 | return `ARRAY[${p.map(DatabaseLogger.serialize).join(", ")}]`; 28 | } 29 | if (typeof p === "object") { 30 | // Assume it's a JSON object 31 | return DatabaseLogger.stringLiteral(JSON.stringify(p)); 32 | } 33 | return DatabaseLogger.stringLiteral(String(p)); 34 | } 35 | 36 | static stringLiteral(s: string) { 37 | if (/\\'\n\r\t\b\f/.exec(s)) { 38 | let str = s; 39 | str = str.replaceAll("\\", "\\\\"); 40 | str = str.replaceAll("'", "\\'"); 41 | str = str.replaceAll("\n", "\\n"); 42 | str = str.replaceAll("\r", "\\r"); 43 | str = str.replaceAll("\t", "\\t"); 44 | str = str.replaceAll("\b", "\\b"); 45 | str = str.replaceAll("\f", "\\f"); 46 | return `E'${str}'`; 47 | } 48 | return `'${s}'`; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /models/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerspub/models", 3 | "version": "0.1.0", 4 | "exports": { 5 | "./account": "./account.ts", 6 | "./actor": "./actor.ts", 7 | "./article": "./article.ts", 8 | "./avatar": "./avatar.ts", 9 | "./blocking": "./blocking.ts", 10 | "./context": "./context.ts", 11 | "./date": "./date.ts", 12 | "./db": "./db.ts", 13 | "./dblogger": "./dblogger.ts", 14 | "./emoji": "./emoji.ts", 15 | "./following": "./following.ts", 16 | "./html": "./html.ts", 17 | "./i18n": "./i18n.ts", 18 | "./instance": "./instance.ts", 19 | "./langdet": "./langdet.ts", 20 | "./markup": "./markup.ts", 21 | "./medium": "./medium.ts", 22 | "./note": "./note.ts", 23 | "./notification": "./notification.ts", 24 | "./passkey": "./passkey.ts", 25 | "./poll": "./poll.ts", 26 | "./post": "./post.ts", 27 | "./reaction": "./reaction.ts", 28 | "./relations": "./relations.ts", 29 | "./schema": "./schema.ts", 30 | "./search": "./search.ts", 31 | "./session": "./session.ts", 32 | "./signin": "./signin.ts", 33 | "./signup": "./signup.ts", 34 | "./timeline": "./timeline.ts", 35 | "./tx": "./tx.ts", 36 | "./url": "./url.ts", 37 | "./uuid": "./uuid.ts" 38 | }, 39 | "lint": { 40 | "rules": { 41 | "exclude": [ 42 | "no-slow-types" 43 | ] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /models/emoji.ts: -------------------------------------------------------------------------------- 1 | export const REACTION_EMOJIS = [ 2 | "❤️", 3 | "🎉", 4 | "😂", 5 | "😲", 6 | "🤔", 7 | "😢", 8 | "👀", 9 | ] as const; 10 | export type ReactionEmoji = (typeof REACTION_EMOJIS)[number]; 11 | 12 | export function isReactionEmoji(value: unknown): value is ReactionEmoji { 13 | return REACTION_EMOJIS.includes(value as ReactionEmoji); 14 | } 15 | 16 | export const DEFAULT_REACTION_EMOJI = REACTION_EMOJIS[0]; 17 | 18 | const CUSTOM_EMOJI_REGEXP = /:([a-z0-9_-]+):/gi; 19 | const HTML_ELEMENT_REGEXP = /<\/?[^>]+>/g; 20 | 21 | export function renderCustomEmojis( 22 | html: string, 23 | emojis: Record, 24 | ): string; 25 | export function renderCustomEmojis( 26 | html: null, 27 | emojis: Record, 28 | ): null; 29 | export function renderCustomEmojis( 30 | html: undefined, 31 | emojis: Record, 32 | ): undefined; 33 | export function renderCustomEmojis( 34 | html: string | null, 35 | emojis: Record, 36 | ): string | null; 37 | export function renderCustomEmojis( 38 | html: string | undefined, 39 | emojis: Record, 40 | ): string | undefined; 41 | export function renderCustomEmojis( 42 | html: string | null | undefined, 43 | emojis: Record, 44 | ): string | null | undefined; 45 | 46 | export function renderCustomEmojis( 47 | html: string | null | undefined, 48 | emojis: Record, 49 | ): string | null | undefined { 50 | if (html == null) return html; 51 | let result = ""; 52 | let index = 0; 53 | for (const match of html.matchAll(HTML_ELEMENT_REGEXP)) { 54 | result += replaceEmojis(html.substring(index, match.index)); 55 | result += match[0]; 56 | index = match.index + match[0].length; 57 | } 58 | result += replaceEmojis(html.substring(index)); 59 | return result; 60 | 61 | function replaceEmojis(html: string): string { 62 | return html.replaceAll(CUSTOM_EMOJI_REGEXP, (match) => { 63 | const emoji = emojis[match] ?? emojis[match.replace(/^:|:$/g, "")]; 64 | if (emoji == null) return match; 65 | return `${match}`; 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /models/i18n.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { findNearestLocale, type Locale } from "./i18n.ts"; 3 | 4 | Deno.test("findNearestLocale()", async (t) => { 5 | await t.step("exact match", () => { 6 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 7 | const result = findNearestLocale("en-US", availableLocales); 8 | assertEquals(result, "en-US"); 9 | }); 10 | 11 | await t.step("match base locale when full locale provided", () => { 12 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 13 | const result = findNearestLocale("ko-KR", availableLocales); 14 | assertEquals(result, "ko"); 15 | }); 16 | 17 | await t.step("match with region when base locale provided", () => { 18 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 19 | const result = findNearestLocale("zh", availableLocales); 20 | assertEquals(result, "zh-HK"); 21 | }); 22 | 23 | await t.step("match with different region", () => { 24 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 25 | const result = findNearestLocale("zh-CN", availableLocales); 26 | assertEquals(result, "zh-HK"); 27 | }); 28 | 29 | await t.step("no match returns undefined", () => { 30 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 31 | const result = findNearestLocale("fr", availableLocales); 32 | assertEquals(result, undefined); 33 | }); 34 | 35 | await t.step("empty locale list returns undefined", () => { 36 | const result = findNearestLocale("en", []); 37 | assertEquals(result, undefined); 38 | }); 39 | 40 | await t.step("case insensitivity", () => { 41 | const availableLocales: Locale[] = ["en-US", "ko", "zh-HK"]; 42 | const result = findNearestLocale("EN-US", availableLocales); 43 | assertEquals(result, "en-US"); 44 | const result2 = findNearestLocale("ZH-hk", availableLocales); 45 | assertEquals(result2, "zh-HK"); 46 | const result3 = findNearestLocale("KO-KR", availableLocales); 47 | assertEquals(result3, "ko"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /models/instance.ts: -------------------------------------------------------------------------------- 1 | import { formatSemVer, getNodeInfo } from "@fedify/fedify"; 2 | import { eq, sql } from "drizzle-orm"; 3 | import type { Database } from "./db.ts"; 4 | import { type Instance, instanceTable, type NewInstance } from "./schema.ts"; 5 | 6 | export interface PersistInstanceOptions { 7 | skipUpdate?: boolean; 8 | } 9 | 10 | export async function persistInstance( 11 | db: Database, 12 | host: string, 13 | options: PersistInstanceOptions = {}, 14 | ): Promise { 15 | if (options.skipUpdate) { 16 | const instance = await db.query.instanceTable.findFirst({ 17 | where: { host }, 18 | }); 19 | if (instance != null) return instance; 20 | } 21 | const nodeInfo = await getNodeInfo( 22 | `https://${host}/`, 23 | { parse: "best-effort" }, 24 | ); 25 | const values: NewInstance = { 26 | host, 27 | software: nodeInfo?.software?.name ?? null, 28 | softwareVersion: nodeInfo?.software == null || 29 | formatSemVer(nodeInfo.software.version) === "0.0.0" 30 | ? null 31 | : formatSemVer(nodeInfo.software.version), 32 | }; 33 | const rows = await db.insert(instanceTable) 34 | .values(values) 35 | .onConflictDoUpdate({ 36 | target: instanceTable.host, 37 | set: { 38 | ...values, 39 | updated: sql`CURRENT_TIMESTAMP`, 40 | }, 41 | setWhere: eq(instanceTable.host, host), 42 | }) 43 | .returning(); 44 | return rows[0]; 45 | } 46 | -------------------------------------------------------------------------------- /models/langdet.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@logtape/logtape"; 2 | import { detectAll } from "tinyld"; 3 | 4 | const logger = getLogger(["hackerspub", "models", "langdet"]); 5 | 6 | export interface DetectLanguageOptions { 7 | text: string; 8 | acceptLanguage: string | null; 9 | } 10 | 11 | export function detectLanguage(options: DetectLanguageOptions): string | null { 12 | const acceptLanguages = parseAcceptLanguage(options.acceptLanguage ?? ""); 13 | const langDetect = detectAll(sanitizeText(options.text)); 14 | for (let i = 0; i < langDetect.length; i++) { 15 | langDetect[i].accuracy = (langDetect[i].accuracy + 16 | (acceptLanguages[langDetect[i].lang] ?? acceptLanguages["*"] ?? 0)) / 2; 17 | } 18 | langDetect.sort((a, b) => b.accuracy - a.accuracy); 19 | logger.debug("Detected languages: {languages}", { languages: langDetect }); 20 | if (langDetect.length < 1) return null; 21 | const detectedLang = langDetect[0].lang; 22 | const language = detectedLang ?? null; 23 | logger.debug("Detected language: {language}", { language }); 24 | return language; 25 | } 26 | 27 | function parseAcceptLanguage(acceptLanguage: string): Record { 28 | const langs: [string, number][] = acceptLanguage.split(",").map((lang) => { 29 | const [code, q] = lang.trim().split(";").map((s) => s.trim()); 30 | return [code.substring(0, 2), q == null ? 1 : parseFloat(q.split("=")[1])]; 31 | }); 32 | langs.sort((a, b) => b[1] - a[1]); 33 | return Object.fromEntries(langs); 34 | } 35 | 36 | function sanitizeText(text: string): string { 37 | const URL_PATTERN = /https?:\/\/[^\s]+/g; 38 | const MENTION_PATTERN = 39 | /@[\p{L}\p{N}._-]+(@(?:[\p{L}\p{N}][\p{L}\p{N}_-]*\.)+[\p{L}\p{N}]{2,})?/giu; 40 | 41 | return text.replaceAll(URL_PATTERN, "") 42 | .replaceAll(MENTION_PATTERN, ""); 43 | } 44 | -------------------------------------------------------------------------------- /models/session.ts: -------------------------------------------------------------------------------- 1 | import type Keyv from "keyv"; 2 | import type { Uuid } from "./uuid.ts"; 3 | 4 | const KV_NAMESPACE = "session"; 5 | 6 | export const EXPIRATION: Temporal.Duration = Temporal.Duration.from({ 7 | hours: 24 * 365, 8 | }); 9 | 10 | export interface Session { 11 | id: Uuid; 12 | accountId: Uuid; 13 | userAgent?: string | null; 14 | ipAddress?: string | null; 15 | created: Date; 16 | } 17 | 18 | export async function createSession( 19 | kv: Keyv, 20 | session: 21 | & Omit 22 | & Pick, "id" | "created">, 23 | ): Promise { 24 | const id = session.id ?? crypto.randomUUID(); 25 | const data = { ...session, id, created: session.created ?? new Date() }; 26 | await kv.set(`${KV_NAMESPACE}/${id}`, data, EXPIRATION.total("millisecond")); 27 | return data; 28 | } 29 | 30 | export function getSession( 31 | kv: Keyv, 32 | sessionId: Uuid, 33 | ): Promise { 34 | return kv.get(`${KV_NAMESPACE}/${sessionId}`); 35 | } 36 | 37 | export async function deleteSession( 38 | kv: Keyv, 39 | sessionId: Uuid, 40 | ): Promise { 41 | await kv.delete(`${KV_NAMESPACE}/${sessionId}`); 42 | } 43 | -------------------------------------------------------------------------------- /models/signin.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@logtape/logtape"; 2 | import { encodeBase64Url } from "@std/encoding/base64url"; 3 | import type Keyv from "keyv"; 4 | import type { Uuid } from "./uuid.ts"; 5 | 6 | const logger = getLogger(["hackerspub", "models", "signin"]); 7 | 8 | const KV_NAMESPACE = "signin"; 9 | 10 | export const EXPIRATION: Temporal.Duration = Temporal.Duration.from({ 11 | hours: 12, 12 | }); 13 | 14 | export const USERNAME_REGEXP = /^[a-z0-9_]{1,15}$/; 15 | 16 | export interface SigninToken { 17 | accountId: Uuid; 18 | token: Uuid; 19 | code: string; 20 | created: Date; 21 | } 22 | 23 | export async function createSigninToken( 24 | kv: Keyv, 25 | accountId: Uuid, 26 | ): Promise { 27 | const token = crypto.randomUUID(); 28 | const buffer = new Uint8Array(8); 29 | crypto.getRandomValues(buffer); 30 | const tokenData: SigninToken = { 31 | accountId, 32 | token, 33 | code: encodeBase64Url(buffer), 34 | created: new Date(), 35 | }; 36 | await kv.set( 37 | `${KV_NAMESPACE}/${token}`, 38 | tokenData, 39 | EXPIRATION.total("millisecond"), 40 | ); 41 | logger.debug("Created sign-in token (expires in {expires}): {token}", { 42 | expires: EXPIRATION, 43 | token: tokenData, 44 | }); 45 | return tokenData; 46 | } 47 | 48 | export function getSigninToken( 49 | kv: Keyv, 50 | token: Uuid, 51 | ): Promise { 52 | return kv.get(`${KV_NAMESPACE}/${token}`); 53 | } 54 | 55 | export async function deleteSigninToken( 56 | kv: Keyv, 57 | token: Uuid, 58 | ): Promise { 59 | await kv.delete(`${KV_NAMESPACE}/${token}`); 60 | } 61 | -------------------------------------------------------------------------------- /models/tx.ts: -------------------------------------------------------------------------------- 1 | import type { RequestContext } from "@fedify/fedify"; 2 | import type { ContextData } from "./context.ts"; 3 | import type { Transaction } from "./db.ts"; 4 | 5 | export async function withTransaction( 6 | context: RequestContext, 7 | callback: (context: RequestContext>) => Promise, 8 | ) { 9 | return await context.data.db.transaction(async (transaction) => { 10 | const nextContext = context.federation.createContext(context.request, { 11 | ...context.data, 12 | db: transaction, 13 | }) as RequestContext>; 14 | return await callback(nextContext); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /models/url.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert/equals"; 2 | import { compactUrl } from "./url.ts"; 3 | 4 | Deno.test("compactUrl()", () => { 5 | assertEquals( 6 | compactUrl("https://example.com/"), 7 | "example.com", 8 | ); 9 | assertEquals( 10 | compactUrl("https://example.com/test/"), 11 | "example.com/test", 12 | ); 13 | assertEquals( 14 | compactUrl("https://example.com/test/?"), 15 | "example.com/test", 16 | ); 17 | assertEquals( 18 | compactUrl("https://example.com/test/?#"), 19 | "example.com/test", 20 | ); 21 | assertEquals( 22 | compactUrl("https://example.com/test/?#asdf"), 23 | "example.com/test/#asdf", 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /models/url.ts: -------------------------------------------------------------------------------- 1 | export function compactUrl(url: string | URL): string { 2 | url = new URL(url); 3 | return url.protocol !== "https:" && url.protocol !== "http:" 4 | ? url.href 5 | : url.host + 6 | (url.searchParams.size < 1 && (url.hash === "" || url.hash === "#") 7 | ? url.pathname.replace(/\/+$/, "") 8 | : url.pathname) + 9 | (url.searchParams.size < 1 ? "" : url.search) + 10 | (url.hash === "#" ? "" : url.hash); 11 | } 12 | -------------------------------------------------------------------------------- /models/uuid.ts: -------------------------------------------------------------------------------- 1 | import { validate as validateUuidV1To5 } from "@std/uuid"; 2 | import { generate, validate as validateUuidV7 } from "@std/uuid/unstable-v7"; 3 | 4 | export type Uuid = ReturnType; 5 | 6 | export function generateUuidV7(): Uuid { 7 | return generate() as Uuid; 8 | } 9 | 10 | export function validateUuid(string: unknown): string is Uuid { 11 | return typeof string === "string" && 12 | (validateUuidV1To5(string) || validateUuidV7(string)); 13 | } 14 | -------------------------------------------------------------------------------- /scripts/addaccount.ts: -------------------------------------------------------------------------------- 1 | import { createSignupToken } from "@hackerspub/models/signup"; 2 | import { kv } from "@hackerspub/web/kv"; 3 | import { ORIGIN } from "../web/federation.ts"; 4 | 5 | export async function createSignupLink(email: string): Promise { 6 | const token = await createSignupToken(kv, email); 7 | const verifyUrl = new URL(`/sign/up/${token.token}`, ORIGIN); 8 | verifyUrl.searchParams.set("code", token.code); 9 | return verifyUrl; 10 | } 11 | 12 | export async function main() { 13 | const email = Deno.args[0]; 14 | if (!email) { 15 | console.error("Error: Please provide an email address."); 16 | console.error("Usage: deno task addaccount EMAIL"); 17 | Deno.exit(1); 18 | } 19 | try { 20 | const signupLink = await createSignupLink(email); 21 | console.error(`Signup link for ${email}:\n`); 22 | console.log(signupLink.href); 23 | } catch (error) { 24 | console.error("Error creating signup link:", error); 25 | } 26 | } 27 | 28 | if (import.meta.main) await main(); 29 | -------------------------------------------------------------------------------- /scripts/keygen.ts: -------------------------------------------------------------------------------- 1 | import { exportJwk, generateCryptoKeyPair } from "@fedify/fedify"; 2 | 3 | const keyPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); 4 | const jwk = await exportJwk(keyPair.privateKey); 5 | console.log(JSON.stringify(jwk)); 6 | -------------------------------------------------------------------------------- /web-next/.env: -------------------------------------------------------------------------------- 1 | ../.env -------------------------------------------------------------------------------- /web-next/.gitignore: -------------------------------------------------------------------------------- 1 | # Vinxi outputs 2 | dist 3 | .output 4 | .vinxi 5 | app.config.timestamp_*.js 6 | __generated__/ 7 | !.env 8 | -------------------------------------------------------------------------------- /web-next/app.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@solidjs/start/config"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { cjsInterop } from "vite-plugin-cjs-interop"; 4 | import relay from "vite-plugin-relay-lite"; 5 | 6 | export default defineConfig({ 7 | vite: () => ({ 8 | server: { 9 | fs: { 10 | // For serving Pretendard Variable dynamic subsets 11 | // Deno installs the dependencies at the root of the workspace, 12 | // so the subset files are located inside the root node_modules. 13 | allow: ["../node_modules"], 14 | }, 15 | }, 16 | plugins: [ 17 | tailwindcss(), 18 | relay(), 19 | cjsInterop({ dependencies: ["relay-runtime"] }), 20 | ], 21 | }), 22 | }); 23 | -------------------------------------------------------------------------------- /web-next/deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", 3 | "imports": { 4 | "~/": "./src/" 5 | }, 6 | "tasks": { 7 | "codegen": "relay-compiler", 8 | "dev": "vinxi dev", 9 | "build": "vinxi build" 10 | }, 11 | "lint": { 12 | "rules": { 13 | "exclude": [ 14 | // SolidStart server functions must be async functions at all time 15 | "require-await" 16 | ] 17 | } 18 | }, 19 | "compilerOptions": { 20 | "jsx": "react-jsx", 21 | "jsxImportSource": "solid-js", 22 | "types": ["vinxi/types/client"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web-next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerspub/web-next", 3 | "type": "module", 4 | "dependencies": { 5 | "@kobalte/core": "^0.13.9", 6 | "@solidjs/meta": "^0.29.4", 7 | "@solidjs/router": "^0.15.0", 8 | "@solidjs/start": "^1.1.0", 9 | "class-variance-authority": "^0.7.1", 10 | "clsx": "^2.1.1", 11 | "pretendard": "^1.3.9", 12 | "relay-runtime": "^19.0.0", 13 | "solid-js": "^1.9.5", 14 | "solid-relay": "^1.0.0-beta.10", 15 | "tailwind-merge": "^3.3.0", 16 | "tailwindcss": "^4.1.7", 17 | "tw-animate-css": "^1.3.0", 18 | "vinxi": "^0.5.3" 19 | }, 20 | "devDependencies": { 21 | "@tailwindcss/vite": "^4.1.7", 22 | "@types/relay-runtime": "^19.0.1", 23 | "relay-compiler": "^19.0.0", 24 | "vite-plugin-cjs-interop": "^2.2.0", 25 | "vite-plugin-relay-lite": "^0.11.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web-next/relay.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/relay-compiler/relay-compiler-config-schema.json", 3 | "src": "./src", 4 | "language": "typescript", 5 | "schema": "../graphql/schema.graphql", 6 | "eagerEsModules": true 7 | } 8 | -------------------------------------------------------------------------------- /web-next/src/RelayEnvironment.tsx: -------------------------------------------------------------------------------- 1 | import type { FetchFunction, IEnvironment } from "relay-runtime"; 2 | import { Environment, Network, RecordSource, Store } from "relay-runtime"; 3 | 4 | const fetchFn: FetchFunction = async ( 5 | params, 6 | variables, 7 | ) => { 8 | if (!params.text) throw new Error("Operation document must be provided"); 9 | 10 | const response = await fetch(import.meta.env.VITE_API_URL, { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | Accept: "application/json", 15 | }, 16 | credentials: "include", 17 | body: JSON.stringify({ query: params.text, variables }), 18 | }); 19 | 20 | return await response.json(); 21 | }; 22 | 23 | export function createEnvironment(): IEnvironment { 24 | const network = Network.create((...args) => { 25 | return fetchFn(...args); 26 | }); 27 | const store = new Store(new RecordSource()); 28 | return new Environment({ store, network }); 29 | } 30 | -------------------------------------------------------------------------------- /web-next/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { MetaProvider, Title } from "@solidjs/meta"; 2 | import { Router } from "@solidjs/router"; 3 | import { FileRoutes } from "@solidjs/start/router"; 4 | import { Suspense } from "solid-js"; 5 | import { RelayEnvironmentProvider } from "solid-relay"; 6 | import { createEnvironment } from "./RelayEnvironment.tsx"; 7 | 8 | import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css"; 9 | import "~/app.css"; 10 | 11 | export default function App() { 12 | const environment = createEnvironment(); 13 | 14 | return ( 15 | ( 17 | 18 | 19 | Hackers' Pub 20 | {props.children} 21 | 22 | 23 | )} 24 | > 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /web-next/src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { mount, StartClient } from "@solidjs/start/client"; 3 | 4 | mount(() => , document.getElementById("app")!); 5 | -------------------------------------------------------------------------------- /web-next/src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { createHandler, StartServer } from "@solidjs/start/server"; 3 | 4 | export default createHandler(() => ( 5 | ( 7 | 8 | 9 | 10 | 11 | 12 | {assets} 13 | 14 | 15 |
{children}
16 | {scripts} 17 | 18 | 19 | )} 20 | /> 21 | )); 22 | -------------------------------------------------------------------------------- /web-next/src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web-next/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /web-next/src/routes/[...404].tsx: -------------------------------------------------------------------------------- 1 | import { Title } from "@solidjs/meta"; 2 | import { HttpStatusCode } from "@solidjs/start"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | Not Found 8 | 9 |

Page Not Found

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /web-next/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { query, type RouteDefinition } from "@solidjs/router"; 2 | import { graphql } from "relay-runtime"; 3 | import { Show } from "solid-js"; 4 | import { 5 | createPreloadedQuery, 6 | loadQuery, 7 | useRelayEnvironment, 8 | } from "solid-relay"; 9 | import type { routesQuery } from "./__generated__/routesQuery.graphql.ts"; 10 | 11 | const RoutesQuery = graphql` 12 | query routesQuery { 13 | instanceByHost(host: "hackers.pub") { 14 | host 15 | software 16 | softwareVersion 17 | } 18 | } 19 | `; 20 | 21 | const loadRoutesQuery = query( 22 | () => loadQuery(useRelayEnvironment()(), RoutesQuery, {}), 23 | "loadRoutesQuery", 24 | ); 25 | 26 | export const route = { 27 | preload() { 28 | void loadRoutesQuery(); 29 | }, 30 | } satisfies RouteDefinition; 31 | 32 | export default function Home() { 33 | const data = createPreloadedQuery( 34 | RoutesQuery, 35 | loadRoutesQuery, 36 | ); 37 | 38 | return ( 39 |
40 | 41 | {(instance) => ( 42 | <> 43 | {instance().host} running {instance().software}{" "} 44 | {instance().softwareVersion} 45 | 46 | )} 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | __generated__/ 3 | -------------------------------------------------------------------------------- /web/ai.ts: -------------------------------------------------------------------------------- 1 | import { anthropic } from "@ai-sdk/anthropic"; 2 | import { google } from "@ai-sdk/google"; 3 | 4 | export const summarizer = google("gemini-2.0-flash"); 5 | export const translator = anthropic("claude-3-7-sonnet-20250219"); 6 | -------------------------------------------------------------------------------- /web/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from "@graphql-codegen/cli"; 2 | 3 | const config: CodegenConfig = { 4 | schema: "../graphql/schema.graphql", 5 | documents: ["**/*.{ts,tsx}", "!node_modules"], 6 | ignoreNoDocuments: true, 7 | emitLegacyCommonJSImports: false, 8 | generates: { 9 | "./graphql/__generated__/": { 10 | preset: "client", 11 | presetConfig: { 12 | extension: ".ts", 13 | }, 14 | config: { 15 | scalars: { 16 | Date: "Date", 17 | DateTime: "Date", 18 | Locale: "string", // FIXME: It should be Locale from @hackerspub/models/i18n 19 | HTML: "string", 20 | Markdown: "string", 21 | MediaType: "string", 22 | URL: "URL", 23 | UUID: "`${string}-${string}-${string}-${string}-${string}`", 24 | }, 25 | strictScalars: true, 26 | useTypeImports: true, 27 | enumsAsConst: true, 28 | skipTypename: true, 29 | }, 30 | }, 31 | }, 32 | hooks: { 33 | beforeOneFileWrite: (_, content: string) => 34 | "//@ts-nocheck\n" + 35 | content.replaceAll(/(from\s+['"].+)\.js(['"])/g, "$1.ts$2"), 36 | }, 37 | }; 38 | 39 | export default config; 40 | -------------------------------------------------------------------------------- /web/components/AdminNav.tsx: -------------------------------------------------------------------------------- 1 | import { Tab, TabNav } from "./TabNav.tsx"; 2 | 3 | export type AdminNavItem = "accounts" | "invitations"; 4 | 5 | export interface AdminNavProps { 6 | active: AdminNavItem; 7 | } 8 | 9 | export function AdminNav({ active }: AdminNavProps) { 10 | return ( 11 | 12 | 13 | Accounts 14 | 15 | 16 | Invitations 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /web/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "preact"; 2 | 3 | export type ButtonProps = JSX.ButtonHTMLAttributes; 4 | 5 | export function Button(props: ButtonProps) { 6 | const propsWithoutClass = { ...props }; 7 | delete propsWithoutClass.class; 8 | delete propsWithoutClass.children; 9 | return ( 10 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /web/components/Excerpt.tsx: -------------------------------------------------------------------------------- 1 | import { renderCustomEmojis } from "@hackerspub/models/emoji"; 2 | import { sanitizeExcerptHtml } from "@hackerspub/models/html"; 3 | 4 | export interface ExcerptProps { 5 | class?: string; 6 | html: string; 7 | emojis?: Record; 8 | lang?: string | null; 9 | } 10 | 11 | export function Excerpt( 12 | { class: className, html, emojis, lang }: ExcerptProps, 13 | ) { 14 | return ( 15 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /web/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "preact"; 2 | 3 | export type InputProps = JSX.InputHTMLAttributes; 4 | 5 | export function Input(props: InputProps) { 6 | const propsWithoutClass = { ...props }; 7 | delete propsWithoutClass.class; 8 | return ( 9 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /web/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentChildren } from "preact"; 2 | 3 | export interface LabelProps { 4 | label: string; 5 | required?: boolean; 6 | children: ComponentChildren; 7 | class?: string; 8 | } 9 | 10 | export function Label({ class: klass, label, required, children }: LabelProps) { 11 | return ( 12 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /web/components/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentChildren } from "preact"; 2 | 3 | export interface PageTitleProps { 4 | class?: string; 5 | children?: ComponentChildren; 6 | subtitle?: { 7 | text: ComponentChildren; 8 | class?: string; 9 | }; 10 | } 11 | 12 | export function PageTitle(props: PageTitleProps) { 13 | return ( 14 |
17 |

20 | {props.children} 21 |

22 | {props.subtitle && ( 23 |

26 | {props.subtitle.text} 27 |

28 | )} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /web/components/PostPagination.tsx: -------------------------------------------------------------------------------- 1 | import { Msg } from "./Msg.tsx"; 2 | 3 | export interface PostPaginationProps { 4 | nextHref?: string | URL; 5 | } 6 | 7 | export function PostPagination({ nextHref }: PostPaginationProps) { 8 | return ( 9 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /web/components/PostReactionsNav.tsx: -------------------------------------------------------------------------------- 1 | import { Msg, type MsgKey } from "./Msg.tsx"; 2 | import { Tab, TabNav } from "./TabNav.tsx"; 3 | 4 | export type PostReactionsNavItem = "reactions" | "sharers"; 5 | 6 | export interface PostReactionsNavProps { 7 | active: PostReactionsNavItem; 8 | hrefs: Record; 9 | stats: Record; 10 | } 11 | 12 | const labels: [PostReactionsNavItem, MsgKey][] = [ 13 | ["reactions", "post.reactions.title"], 14 | ["sharers", "post.reactions.sharers"], 15 | ]; 16 | 17 | export function PostReactionsNav( 18 | { active, hrefs, stats }: PostReactionsNavProps, 19 | ) { 20 | return ( 21 | 22 | {labels.map(([key, label]) => ( 23 | 24 | 25 | ({stats[key]}) 26 | 27 | ))} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /web/components/ProfileNav.tsx: -------------------------------------------------------------------------------- 1 | import { Msg, Translation } from "./Msg.tsx"; 2 | import { Tab, TabNav } from "./TabNav.tsx"; 3 | 4 | export type ProfileNavItem = 5 | | "total" 6 | | "notes" 7 | | "notesWithReplies" 8 | | "shares" 9 | | "articles"; 10 | 11 | export interface ProfileNavProps { 12 | active: ProfileNavItem; 13 | stats: Record; 14 | profileHref: string; 15 | class?: string; 16 | } 17 | 18 | export function ProfileNav( 19 | { active, stats, profileHref, class: cls }: ProfileNavProps, 20 | ) { 21 | return ( 22 | 23 | {(_, lang) => ( 24 | 25 | 26 | 30 | 31 | 32 | 36 | 37 | 41 | 45 | 46 | 47 | 51 | 52 | 56 | 60 | 61 | 62 | )} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /web/components/SettingsNav.tsx: -------------------------------------------------------------------------------- 1 | import { Msg } from "./Msg.tsx"; 2 | import { Tab, TabNav } from "./TabNav.tsx"; 3 | 4 | export type SettingsNavItem = 5 | | "profile" 6 | | "preferences" 7 | | "language" 8 | | "invite" 9 | | "passkeys"; 10 | 11 | export interface SettingsNavProps { 12 | active: SettingsNavItem; 13 | settingsHref: string; 14 | leftInvitations: number; 15 | } 16 | 17 | export function SettingsNav( 18 | { active, settingsHref, leftInvitations }: SettingsNavProps, 19 | ) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ({leftInvitations}) 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /web/components/TabNav.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentChildren } from "preact"; 2 | 3 | export interface TabProps { 4 | selected: boolean; 5 | href: string; 6 | children: ComponentChildren; 7 | } 8 | 9 | export function Tab({ selected, href, children }: TabProps) { 10 | return selected 11 | ? ( 12 | 16 | {children} 17 | 18 | ) 19 | : ( 20 | 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | export interface TabNavProps { 30 | children: ComponentChildren; 31 | class?: string; 32 | } 33 | 34 | export function TabNav(props: TabNavProps) { 35 | return ( 36 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /web/components/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "preact"; 2 | 3 | export type TextAreaProps = JSX.TextareaHTMLAttributes; 4 | 5 | export function TextArea(props: TextAreaProps) { 6 | const propsWithoutClass = { ...props }; 7 | delete propsWithoutClass.class; 8 | return ( 9 |