├── .cursorrules ├── .devcontainer └── devcontainer.json ├── .dir-locals.el ├── .dockerignore ├── .editorconfig ├── .env.sample ├── .env.test ├── .github ├── FUNDING.yaml ├── copilot-instructions.md └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .windsurfrules ├── .zed └── settings.json ├── CHANGES.md ├── CLAUDE.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── bin ├── routes.ts └── server.ts ├── biome.json ├── compose-fs.yaml ├── compose.yaml ├── cspell.json ├── docs ├── .gitignore ├── .vscode │ ├── extensions.json │ └── launch.json ├── astro.config.mjs ├── bun.lockb ├── package.json ├── public │ ├── favicon-darkmode.svg │ └── favicon.svg ├── src │ ├── assets │ │ ├── logo-black.svg │ │ └── logo-white.svg │ ├── content │ │ ├── config.ts │ │ └── docs │ │ │ ├── clients.mdx │ │ │ ├── index.mdx │ │ │ ├── install │ │ │ ├── docker.mdx │ │ │ ├── docker │ │ │ │ └── compose-yaml │ │ │ ├── env.mdx │ │ │ ├── manual.mdx │ │ │ ├── railway.mdx │ │ │ ├── railway │ │ │ │ ├── deployments.png │ │ │ │ └── project.png │ │ │ ├── setup.mdx │ │ │ └── setup │ │ │ │ ├── accounts-empty.png │ │ │ │ ├── accounts.png │ │ │ │ ├── authorize.png │ │ │ │ ├── create-account.png │ │ │ │ ├── phanpy-login.png │ │ │ │ ├── phanpy.png │ │ │ │ └── setup.png │ │ │ ├── intro.md │ │ │ ├── ja │ │ │ ├── clients.mdx │ │ │ ├── index.mdx │ │ │ ├── install │ │ │ │ ├── docker.mdx │ │ │ │ ├── env.mdx │ │ │ │ ├── manual.mdx │ │ │ │ ├── railway.mdx │ │ │ │ └── setup.mdx │ │ │ └── intro.md │ │ │ ├── ko │ │ │ ├── clients.mdx │ │ │ ├── index.mdx │ │ │ ├── install │ │ │ │ ├── docker.mdx │ │ │ │ ├── env.mdx │ │ │ │ ├── manual.mdx │ │ │ │ ├── railway.mdx │ │ │ │ └── setup.mdx │ │ │ └── intro.md │ │ │ └── zh-cn │ │ │ ├── clients.mdx │ │ │ ├── index.mdx │ │ │ ├── install │ │ │ ├── docker.mdx │ │ │ ├── env.mdx │ │ │ ├── manual.mdx │ │ │ ├── railway.mdx │ │ │ └── setup.mdx │ │ │ └── intro.md │ ├── env.d.ts │ └── styles │ │ └── custom.css └── tsconfig.json ├── drizzle.config.ts ├── drizzle ├── 0000_init.sql ├── 0001_accounts.sql ├── 0002_Rename.sql ├── 0003_applications.sql ├── 0004_make.sql ├── 0005_access.sql ├── 0006_access_tokens.accout_owner_id.sql ├── 0007_posts.sql ├── 0008_fields.sql ├── 0009_follows.sql ├── 0010_follows.approved.sql ├── 0011_follows.iri.sql ├── 0012_account_owners.handle.sql ├── 0013_likes.sql ├── 0014_bookmarks.sql ├── 0015_delete-cascade.sql ├── 0016_delete-cascade.sql ├── 0017_followed_tags.sql ├── 0018_preview_card.sql ├── 0019_more.sql ├── 0020_status_source.sql ├── 0021_markers.sql ├── 0022_media.sql ├── 0023_ed25519-keys.sql ├── 0024_pinned_posts.sql ├── 0025_accounts.featured_url.sql ├── 0026_featured_tags.sql ├── 0027_lists.sql ├── 0028_polls.sql ├── 0029_poll_options.votes_count.sql ├── 0030_polls.voters_count.sql ├── 0031_Question_type.sql ├── 0032_Make.sql ├── 0033_steep_santa_claus.sql ├── 0034_graceful_robbie_robertson.sql ├── 0035_indices.sql ├── 0036_emojis.sql ├── 0037_reactions.sql ├── 0038_remove.sql ├── 0039_custom_emojis.sql ├── 0040_emoji_iri.sql ├── 0041_change_mutes_duration_type_to_interval.sql ├── 0042_blocks.sql ├── 0043_accounts.successor_id.sql ├── 0044_current_timestamp.sql ├── 0045_accounts.aliases.sql ├── 0046_cascade.sql ├── 0047_cascade.sql ├── 0048_accounts.software.sql ├── 0049_totps.sql ├── 0050_reports.sql ├── 0051_change_reports_comment_to_not_null.sql ├── 0052_organic_ravenous.sql ├── 0053_instances.sql ├── 0054_indices.sql ├── 0055_indices.sql ├── 0056_posts.idempotence_key.sql ├── 0057_indices.sql ├── 0058_update-drizzle-orm.sql ├── 0059_timeline_inboxes.sql ├── 0060_account_owners.discoverable.sql ├── 0061_unique-posts.actor_id-posts.sharing_id.sql ├── 0062_follows_follower_id_accounts_id_fk.sql ├── 0063_theme_color.sql ├── 0064_oauth_access_grants.sql ├── 0065_change_oauth_access_grants_token_to_code.sql ├── 0066_add_oauth_application_confidentiality.sql ├── 0067_add_pkce_to_access_grants.sql ├── 0068_add_profile_scope.sql ├── 0069_repair-application-confidentiality.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 │ └── _journal.json ├── logo-black.svg ├── logo-white.svg ├── mise.toml ├── package.json ├── pnpm-lock.yaml ├── scripts └── rebuild-timelines.ts ├── src ├── api │ ├── index.ts │ ├── v1 │ │ ├── accounts.test.ts │ │ ├── accounts.ts │ │ ├── apps.test.ts │ │ ├── apps.ts │ │ ├── featured_tags.ts │ │ ├── follow_requests.ts │ │ ├── index.ts │ │ ├── instance.ts │ │ ├── lists.ts │ │ ├── markers.ts │ │ ├── media.ts │ │ ├── notifications.ts │ │ ├── polls.ts │ │ ├── reports.ts │ │ ├── statuses.ts │ │ ├── tags.ts │ │ └── timelines.ts │ └── v2 │ │ ├── index.ts │ │ └── instance.ts ├── components │ ├── AccountForm.tsx │ ├── AccountList.tsx │ ├── DashboardLayout.tsx │ ├── Layout.tsx │ ├── LoginForm.tsx │ ├── NewAccountPage.tsx │ ├── OtpForm.tsx │ ├── Post.tsx │ ├── Profile.tsx │ └── SetupForm.tsx ├── db.ts ├── entities │ ├── account.ts │ ├── emoji.ts │ ├── list.ts │ ├── marker.ts │ ├── medium.ts │ ├── poll.ts │ ├── report.ts │ ├── status.ts │ └── tag.ts ├── env.ts ├── federation │ ├── account.ts │ ├── actor.ts │ ├── collection.ts │ ├── date.ts │ ├── emoji.ts │ ├── federation.ts │ ├── inbox.ts │ ├── index.ts │ ├── nodeinfo.ts │ ├── objects.ts │ ├── post.ts │ └── timeline.ts ├── helpers.test.ts ├── helpers.ts ├── image.tsx ├── index.test.ts ├── index.tsx ├── logging.ts ├── login.ts ├── media.ts ├── oauth.test.ts ├── oauth.tsx ├── oauth │ ├── constants.ts │ ├── endpoints │ │ ├── metadata.test.ts │ │ ├── metadata.ts │ │ ├── revoke.test.ts │ │ ├── revoke.ts │ │ ├── userinfo.test.ts │ │ └── userinfo.ts │ ├── helpers.test.ts │ ├── helpers.ts │ ├── middleware.test.ts │ ├── middleware.ts │ ├── validators.test.ts │ └── validators.ts ├── pages │ ├── accounts.tsx │ ├── auth.tsx │ ├── emojis.test.ts │ ├── emojis.tsx │ ├── federation.tsx │ ├── home │ │ └── index.tsx │ ├── index.tsx │ ├── login.tsx │ ├── logout.tsx │ ├── oauth │ │ ├── authorization.tsx │ │ └── authorization_code.tsx │ ├── profile │ │ ├── index.tsx │ │ └── profilePost.tsx │ ├── setup │ │ └── index.tsx │ └── tags │ │ └── index.tsx ├── previewcard.ts ├── public │ ├── favicon-white.png │ ├── favicon.png │ ├── hollo.css │ ├── pico.amber.min.css │ ├── pico.azure.min.css │ ├── pico.blue.min.css │ ├── pico.colors.min.css │ ├── pico.cyan.min.css │ ├── pico.fuchsia.min.css │ ├── pico.green.min.css │ ├── pico.grey.min.css │ ├── pico.indigo.min.css │ ├── pico.jade.min.css │ ├── pico.lime.min.css │ ├── pico.orange.min.css │ ├── pico.pink.min.css │ ├── pico.pumpkin.min.css │ ├── pico.purple.min.css │ ├── pico.red.min.css │ ├── pico.sand.min.css │ ├── pico.slate.min.css │ ├── pico.violet.min.css │ ├── pico.yellow.min.css │ └── pico.zinc.min.css ├── schema.ts ├── sentry.ts ├── storage.ts ├── text.ts └── uuid.ts ├── tests ├── fixtures │ └── files │ │ └── emoji.png ├── helpers.ts └── helpers │ ├── oauth.ts │ └── web.ts ├── tsconfig.json └── vitest.config.ts /.cursorrules: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hollo_dev", 3 | "dockerComposeFile": "../compose-fs.yaml", 4 | "service": "hollo", 5 | "workspaceFolder": "/app", 6 | "settings": { 7 | "terminal.integrated.shell.linux": "/bin/sh" 8 | }, 9 | "extensions": ["dbaeumer.vscode-eslint", "ms-azuretools.vscode-docker"], 10 | "forwardPorts": [3000], 11 | "postCreateCommand": "bun install --frozen-lockfile --no-cache", 12 | "remoteUser": "root", 13 | "containerEnv": { 14 | "LOG_LEVEL": "debug", 15 | "SECRET_KEY": "suVR2cPip3gGFEc1zCKXXERk3zN5Z9AP", 16 | "BEHIND_PROXY": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((eglot-server-programs . (((html-mode 2 | css-mode css-ts-mode 3 | (js-mode :language-id "javascript") 4 | (js-ts-mode :language-id "javascript") 5 | (typescript-mode :language-id "typescript") 6 | (typescript-ts-mode :language-id "typescript") 7 | (tsx-ts-mode :language-id "typescriptreact") 8 | js-json-mode json-mode json-ts-mode jsonc-mode) 9 | "biome" "lsp-proxy"))) 10 | (eval . (add-hook 'before-save-hook 11 | (lambda () 12 | (eglot-format-buffer) 13 | (when (member major-mode '(js-mode 14 | js-ts-mode 15 | typescript-mode 16 | typescript-ts-mode 17 | tsx-ts-mode)) 18 | (eglot-code-actions (point-min) (point-max) 19 | "source.organizeImports.biome" t)))))))) 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .git/ 3 | .github/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://user:password@localhost:5432/dbname 2 | SENTRY_DSN= 3 | SECRET_KEY=secret_key # generate a secret key with `openssl rand -base64 32` 4 | HOME_URL=https://example.com/ # optional; if present, the home page will redirect to this URL 5 | 6 | LOG_LEVEL=info 7 | LOG_QUERY=false 8 | 9 | BEHIND_PROXY=false 10 | PORT=3000 11 | # BIND=localhost 12 | 13 | # Setting ALLOW_PRIVATE_ADDRESS to true disables SSRF (Server-Side Request Forgery) protection 14 | # Set to true to test in local network 15 | # Will be replaced by list of allowed IPs once https://github.com/dahlia/fedify/issues/157 16 | # is implemented. 17 | ALLOW_PRIVATE_ADDRESS=false 18 | 19 | REMOTE_ACTOR_FETCH_POSTS=10 20 | 21 | # File uploads and media storage: 22 | DRIVE_DISK= 23 | STORAGE_URL_BASE= 24 | 25 | # If DRIVE_DISK is "fs": 26 | # FS_STORAGE_PATH= 27 | 28 | # If DRIVE_DISK is "s3": 29 | # AWS_ACCESS_KEY_ID= 30 | # AWS_SECRET_ACCESS_KEY= 31 | # S3_REGION= 32 | # S3_ENDPOINT_URL= 33 | # S3_BUCKET= 34 | # S3_FORCE_PATH_STYLE=false 35 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hollo_test 2 | SECRET_KEY="test_determinist_key_DO_NOT_USE_IN_PRODUCTION" 3 | 4 | # LOG_LEVEL=debug 5 | # LOG_QUERY=true 6 | 7 | # Setting ALLOW_PRIVATE_ADDRESS to true disables SSRF (Server-Side Request Forgery) protection 8 | # Set to true to test in local network 9 | # Will be replaced by list of allowed IPs once https://github.com/dahlia/fedify/issues/157 10 | # is implemented. 11 | ALLOW_PRIVATE_ADDRESS=false 12 | 13 | REMOTE_ACTOR_FETCH_POSTS=10 14 | 15 | # We actually use fake storage in tests: 16 | DRIVE_DISK=fs 17 | FS_STORAGE_PATH=tmp/fakes 18 | STORAGE_URL_BASE="http://hollo.test/" 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: dahlia 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - stable 8 | - "*.*-maintenance" 9 | pull_request: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf 23 | run_install: false 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 24 27 | cache: pnpm 28 | - run: pnpm install 29 | - run: pnpm run check:ci 30 | 31 | test: 32 | runs-on: ubuntu-latest 33 | services: 34 | postgres: 35 | image: postgres:16-alpine 36 | env: 37 | POSTGRES_PASSWORD: postgres 38 | POSTGRES_USER: postgres 39 | POSTGRES_DB: hollo_test 40 | options: >- 41 | --health-cmd "pg_isready -U ${POSTGRES_USER}" 42 | --health-interval 10ms 43 | --health-timeout 3s 44 | --health-retries 50 45 | ports: 46 | - 5432:5432 47 | env: 48 | # HOME_URL=https://localhost/ # optional; if present, the home page will redirect to this URL 49 | DATABASE_URL: postgresql://postgres:postgres@localhost:5432/hollo_test 50 | SECRET_KEY: "test_determinist_key_DO_NOT_USE_IN_PRODUCTION" 51 | LOG_LEVEL: debug 52 | LOG_QUERY: true 53 | 54 | # Setting ALLOW_PRIVATE_ADDRESS to true disables SSRF (Server-Side Request Forgery) protection 55 | # Set to true to test in local network 56 | # Will be replaced by list of allowed IPs once https://github.com/dahlia/fedify/issues/157 57 | # is implemented. 58 | ALLOW_PRIVATE_ADDRESS: false 59 | 60 | DRIVE_DISK: "fs" 61 | FS_STORAGE_PATH: ./tmp/fakes 62 | STORAGE_URL_BASE: "http://hollo.test/" 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: pnpm/action-setup@v4 66 | with: 67 | version: 9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf 68 | run_install: false 69 | - uses: actions/setup-node@v4 70 | with: 71 | node-version: 24 72 | cache: pnpm 73 | - run: pnpm install 74 | - name: Run the database migrations 75 | run: pnpm run migrate 76 | - name: Run the tests 77 | run: pnpm test:ci 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | assets/ 3 | fedify-hollo-*.tgz 4 | *.jsonl 5 | node_modules/ 6 | tmp/ 7 | mise.local.toml 8 | 9 | coverage/** 10 | !coverage/.gitkeep 11 | 12 | **/.claude/settings.local.json 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.detectIndentation": false, 3 | "editor.indentSize": 2, 4 | "editor.insertSpaces": true, 5 | "files.eol": "\n", 6 | "files.insertFinalNewline": true, 7 | "files.trimFinalNewlines": true, 8 | "[css]": { 9 | "editor.defaultFormatter": "biomejs.biome", 10 | "editor.formatOnSave": true 11 | }, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "biomejs.biome", 14 | "editor.formatOnSave": true, 15 | "editor.codeActionsOnSave": { 16 | "source.organizeImports.biome": "always" 17 | } 18 | }, 19 | "[javascriptreact]": { 20 | "editor.defaultFormatter": "biomejs.biome", 21 | "editor.formatOnSave": true, 22 | "editor.codeActionsOnSave": { 23 | "source.organizeImports.biome": "always" 24 | } 25 | }, 26 | "[json]": { 27 | "editor.defaultFormatter": "biomejs.biome", 28 | "editor.formatOnSave": true 29 | }, 30 | "[jsonc]": { 31 | "editor.defaultFormatter": "biomejs.biome", 32 | "editor.formatOnSave": true 33 | }, 34 | "[typescript]": { 35 | "editor.defaultFormatter": "biomejs.biome", 36 | "editor.formatOnSave": true, 37 | "editor.codeActionsOnSave": { 38 | "source.organizeImports.biome": "always" 39 | } 40 | }, 41 | "[typescriptreact]": { 42 | "editor.defaultFormatter": "biomejs.biome", 43 | "editor.formatOnSave": true, 44 | "editor.codeActionsOnSave": { 45 | "source.organizeImports.biome": "always" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_servers": ["biome", "..."], 3 | "code_actions_on_format": { 4 | "source.fixAll.biome": true, 5 | "source.organizeImports.biome": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/node:24.0-alpine 2 | 3 | LABEL org.opencontainers.image.title="Hollo" 4 | LABEL org.opencontainers.image.description="Federated single-user \ 5 | microblogging software" 6 | LABEL org.opencontainers.image.url="https://docs.hollo.social/" 7 | LABEL org.opencontainers.image.source="https://github.com/fedify-dev/hollo" 8 | LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" 9 | 10 | RUN apk add --no-cache ffmpeg jq libstdc++ pnpm 11 | 12 | COPY pnpm-lock.yaml package.json /app/ 13 | WORKDIR /app/ 14 | RUN pnpm install --frozen-lockfile --prod 15 | 16 | COPY . /app/ 17 | 18 | ARG VERSION 19 | LABEL org.opencontainers.image.version="${VERSION}" 20 | RUN \ 21 | if [ "$VERSION" != "" ]; then \ 22 | jq --arg version "$VERSION" '.version = $version' package.json > .pkg.json \ 23 | && mv .pkg.json package.json; \ 24 | fi 25 | 26 | EXPOSE 3000 27 | CMD ["pnpm", "run", "prod"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hollo 8 | ===== 9 | 10 | [![Matrix][Matrix badge]][Matrix] 11 | [![Discord][Discord badge]][Discord] 12 | [![Official Hollo][Official Hollo badge]][Official Hollo] 13 | 14 | Hollo is a federated single-user microblogging software powered by [Fedify]. 15 | Although it is for single-user, it is designed to be federated through 16 | [ActivityPub], which means that you can follow and be followed by other users 17 | from other instances, even from other software that supports ActivityPub like 18 | Mastodon, Misskey, and so on. 19 | 20 | Hollo does not have its own web interface. Instead, it implements 21 | Mastodon-compatible APIs so that you can integrate it with the most of 22 | the [existing Mastodon clients](https://docs.hollo.social/clients/). 23 | 24 | [Matrix badge]: https://img.shields.io/matrix/hollo-users%3Amatrix.org?logo=matrix 25 | [Matrix]: https://matrix.to/#/%23hollo-users:matrix.org 26 | [Discord badge]: https://img.shields.io/discord/1295652627505217647?logo=discord&cacheSeconds=60 27 | [Discord]: https://discord.gg/hGXXxUq2jK 28 | [Official Hollo]: https://hollo.social/@hollo 29 | [Official Hollo badge]: https://fedi-badge.deno.dev/@hollo@hollo.social/followers.svg 30 | [Fedify]: https://fedify.dev/ 31 | [ActivityPub]: https://www.w3.org/TR/activitypub/ 32 | 33 | 34 | Docs 35 | ---- 36 | 37 | - [What is Hollo?](https://docs.hollo.social/intro/) 38 | - Installation 39 | - [Deploy to Railway](https://docs.hollo.social/install/railway/) 40 | - [Deploy using Docker](https://docs.hollo.social/install/docker/) 41 | - [Manual installation](https://docs.hollo.social/install/manual/) 42 | - [Environment variables](https://docs.hollo.social/install/env/) 43 | - [Setting up](https://docs.hollo.social/install/setup/) 44 | - [Tested clients](https://docs.hollo.social/clients/) 45 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Security policy 2 | =============== 3 | 4 | Supported versions 5 | ------------------ 6 | 7 | We support the latest two minor versions of the library. For example, if the 8 | latest version is 1.2.3, we support 1.2.x and 1.1.x. 9 | 10 | 11 | Reporting a vulnerability 12 | ------------------------- 13 | 14 | If you think you have found a security issue, please *do not* open a public 15 | issue. Instead, please open a private vulnerability report by [creating a new 16 | draft security advisory][1]. 17 | 18 | We will review your report and respond within 48 hours. 19 | 20 | [1]: https://github.com/fedify-dev/hollo/security/advisories/new 21 | -------------------------------------------------------------------------------- /bin/routes.ts: -------------------------------------------------------------------------------- 1 | import { showRoutes } from "hono/dev"; 2 | import app from "../src/index"; 3 | 4 | showRoutes(app, { 5 | colorize: true, 6 | }); 7 | -------------------------------------------------------------------------------- /bin/server.ts: -------------------------------------------------------------------------------- 1 | import { isIP } from "node:net"; 2 | import { serve } from "@hono/node-server"; 3 | import { behindProxy } from "x-forwarded-fetch"; 4 | import { configureSentry } from "../src/sentry"; 5 | 6 | import app from "../src/index"; 7 | 8 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 9 | configureSentry(process.env["SENTRY_DSN"]); 10 | 11 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 12 | const BEHIND_PROXY = process.env["BEHIND_PROXY"] === "true"; 13 | 14 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 15 | const BIND = process.env["BIND"]; 16 | 17 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 18 | const PORT = Number.parseInt(process.env["PORT"] ?? "3000", 10); 19 | 20 | if (!Number.isInteger(PORT)) { 21 | console.error("Invalid PORT: must be an integer"); 22 | process.exit(1); 23 | } 24 | 25 | if (BIND && BIND !== "localhost" && !isIP(BIND)) { 26 | console.error( 27 | "Invalid BIND: must be an IP address or localhost, if specified", 28 | ); 29 | process.exit(1); 30 | } 31 | 32 | serve( 33 | { 34 | fetch: BEHIND_PROXY 35 | ? behindProxy(app.fetch.bind(app)) 36 | : app.fetch.bind(app), 37 | port: PORT, 38 | hostname: BIND, 39 | }, 40 | (info) => { 41 | let host = info.address; 42 | // We override it here to show localhost instead of what it resolves to: 43 | if (BIND === "localhost") { 44 | host = "localhost"; 45 | } else if (info.family === "IPv6") { 46 | host = `[${info.address}]`; 47 | } 48 | 49 | console.log(`Listening on http://${host}:${info.port}/`); 50 | }, 51 | ); 52 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [ 8 | ".github/", 9 | "docs/.astro/", 10 | "docs/dist/", 11 | "docs/node_modules/", 12 | "drizzle/", 13 | "src/public/" 14 | ] 15 | }, 16 | "formatter": { 17 | "enabled": true, 18 | "indentStyle": "space" 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "a11y": { 24 | "noRedundantRoles": "off", 25 | "useSemanticElements": "off" 26 | }, 27 | "recommended": true, 28 | "correctness": { 29 | "useJsxKeyInIterable": "off" 30 | }, 31 | "style": { 32 | "noNonNullAssertion": "off" 33 | }, 34 | "suspicious": { 35 | "noShadowRestrictedNames": "off" 36 | } 37 | } 38 | }, 39 | "vcs": { 40 | "enabled": true, 41 | "clientKind": "git", 42 | "useIgnoreFile": true, 43 | "defaultBranch": "main" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /compose-fs.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | hollo: 3 | image: ghcr.io/fedify-dev/hollo:canary 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | DATABASE_URL: "postgres://user:password@postgres:5432/database" 8 | SECRET_KEY: "${SECRET_KEY}" 9 | LOG_LEVEL: "${LOG_LEVEL}" 10 | BEHIND_PROXY: "${BEHIND_PROXY}" 11 | DRIVE_DISK: fs 12 | STORAGE_URL_BASE: http://localhost:3000/assets/ 13 | FS_STORAGE_PATH: /var/lib/hollo 14 | depends_on: 15 | - postgres 16 | volumes: 17 | - assets_data:/var/lib/hollo 18 | restart: unless-stopped 19 | 20 | postgres: 21 | image: postgres:17 22 | environment: 23 | POSTGRES_USER: user 24 | POSTGRES_PASSWORD: password 25 | POSTGRES_DB: database 26 | volumes: 27 | - postgres_data:/var/lib/postgresql/data 28 | restart: unless-stopped 29 | 30 | volumes: 31 | postgres_data: 32 | assets_data: 33 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | hollo: 3 | image: ghcr.io/fedify-dev/hollo:canary 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | DATABASE_URL: "postgres://user:password@postgres:5432/database" 8 | SECRET_KEY: "${SECRET_KEY}" 9 | LOG_LEVEL: "${LOG_LEVEL}" 10 | BEHIND_PROXY: "${BEHIND_PROXY}" 11 | DRIVE_DISK: s3 12 | STORAGE_URL_BASE: http://localhost:9000/hollo/ 13 | S3_REGION: us-east-1 14 | S3_BUCKET: hollo 15 | S3_ENDPOINT_URL: http://minio:9000 16 | S3_FORCE_PATH_STYLE: "true" 17 | AWS_ACCESS_KEY_ID: minioadmin 18 | AWS_SECRET_ACCESS_KEY: minioadmin 19 | depends_on: 20 | - postgres 21 | - minio 22 | - create-bucket 23 | restart: unless-stopped 24 | 25 | postgres: 26 | image: postgres:17 27 | environment: 28 | POSTGRES_USER: user 29 | POSTGRES_PASSWORD: password 30 | POSTGRES_DB: database 31 | volumes: 32 | - postgres_data:/var/lib/postgresql/data 33 | restart: unless-stopped 34 | 35 | minio: 36 | image: minio/minio:RELEASE.2024-09-13T20-26-02Z 37 | ports: 38 | - "9000:9000" 39 | environment: 40 | MINIO_ROOT_USER: minioadmin 41 | MINIO_ROOT_PASSWORD: minioadmin 42 | volumes: 43 | - minio_data:/data 44 | command: ["server", "/data", "--console-address", ":9001"] 45 | 46 | create-bucket: 47 | image: minio/mc:RELEASE.2024-09-16T17-43-14Z 48 | depends_on: 49 | - minio 50 | entrypoint: | 51 | /bin/sh -c " 52 | /usr/bin/mc alias set minio http://minio:9000 minioadmin minioadmin; 53 | /usr/bin/mc mb minio/hollo; 54 | /usr/bin/mc anonymous set public minio/hollo; 55 | exit 0; 56 | " 57 | 58 | volumes: 59 | postgres_data: 60 | minio_data: 61 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "activitypub", 4 | "activitystreams", 5 | "Akkoma", 6 | "astro", 7 | "astrojs", 8 | "bigserial", 9 | "biomejs", 10 | "blurhash", 11 | "bunx", 12 | "datetime", 13 | "favourite", 14 | "favourited", 15 | "favourites", 16 | "fedi", 17 | "fedify", 18 | "fediverse", 19 | "fkey", 20 | "hono", 21 | "htmls", 22 | "idempotency", 23 | "ilike", 24 | "logtape", 25 | "microblog", 26 | "microblogging", 27 | "Misskey", 28 | "multikey", 29 | "nodeinfo", 30 | "opencontainers", 31 | "Phanpy", 32 | "pico", 33 | "previewcard", 34 | "reblog", 35 | "reblogged", 36 | "reblogs", 37 | "shortcode", 38 | "SSRF", 39 | "unbookmark", 40 | "unfavourite", 41 | "unfollow", 42 | "unfollowing", 43 | "unreblog", 44 | "uuidv7", 45 | "webfinger" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /docs/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /docs/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /docs/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/bun.lockb -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.27.1", 14 | "astro": "^4.15.3", 15 | "sharp": "^0.32.5", 16 | "@astrojs/check": "^0.9.3", 17 | "typescript": "^5.6.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from "astro:content"; 2 | import { docsSchema } from "@astrojs/starlight/schema"; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | }; 7 | -------------------------------------------------------------------------------- /docs/src/content/docs/clients.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tested clients 3 | description: >- 4 | Hollo is a headless microblogging software that implements 5 | the Mastodon API. This means that you can use any 6 | Mastodon-compatible client to interact with it. Here are some of 7 | the clients that have been tested with Hollo. 8 | --- 9 | 10 | import { Badge } from '@astrojs/starlight/components'; 11 | 12 | Hollo is a headless microblogging software that implements the Mastodon 13 | API. This means that you can use any Mastodon-compatible client to interact 14 | with it. However, in practice, some clients may not work as expected due to 15 | differences in the way they implement the Mastodon API. Here are some of the 16 | clients that have been tested with Hollo: 17 | 18 | 19 | Web 20 | --- 21 | 22 | ### [Elk](https://elk.zone/) 23 | 24 | - Quotes are write-only.[^1] 25 | - Emoji reactions are not supported. 26 | 27 | [^1]: You can quote a post by pasting a link to the post when you compose 28 | a new post. 29 | 30 | ### [Phanpy](https://phanpy.social/) 31 | 32 | - Emoji reactions are read-only. 33 | 34 | 35 | Android 36 | ------- 37 | 38 | ### [Moshidon](https://lucasggamerm.github.io/moshidon/) 39 | 40 | - You need to turn on *Settings* → *Instance* → *Enable emoji 41 | reactions* to use emoji reactions. 42 | - You need to turn on *Settings* → *Instance* → *Enable post formatting* 43 | and set *Default content type* to *Markdown* to use CommonMark. 44 | 45 | ### [Subway Tooter](https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter) 46 | 47 | - Quotes are write-only.[^1] 48 | 49 | 50 | iOS 51 | --- 52 | 53 | ### [Mona](https://getmona.app/) 54 | 55 | - Emoji reactions are not supported. 56 | 57 | ### [Nightfox DAWN](https://noppe.dev/dawn) 58 | 59 | - To quote you need to manually paste the link to the post.[^1] 60 | 61 | ### [Tusker](https://vaccor.space/tusker/) 62 | 63 | - Quotes are write-only.[^1] 64 | - Emoji reactions are not supported. 65 | 66 | ### [Woolly](https://apps.apple.com/app/woolly-for-mastodon/id6444360628) 67 | 68 | - Quotes are write-only.[^1] 69 | - Emoji reactions are not supported. 70 | 71 | ---- 72 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to Hollo 3 | description: >- 4 | Hollo is a federated microblogging software for single-users. 5 | template: splash 6 | hero: 7 | tagline: >- 8 | A federated microblogging software for single-users. 9 | ActivityPub-enabled, Mastodon-compatible API, supports CommonMark and 10 | Misskey-style quotes. 11 | image: 12 | light: ../../assets/logo-black.svg 13 | dark: ../../assets/logo-white.svg 14 | actions: 15 | - text: What is Hollo? 16 | link: /intro 17 | icon: right-arrow 18 | variant: secondary 19 | - text: Install 20 | link: /install/railway 21 | icon: rocket 22 | variant: primary 23 | - text: Official instance 24 | link: https://hollo.social/@hollo 25 | icon: external 26 | variant: minimal 27 | --- 28 | 29 | import { Card, CardGrid } from "@astrojs/starlight/components"; 30 | 31 | 32 | 33 | Hollo is designed for single-users, so you can own your instance 34 | and have full control over your data. It's perfect for personal microblogs, 35 | notes, and journals. 36 | 37 | 38 | Although Hollo is designed for single-users, you can have multiple accounts 39 | on the same instance. You can switch between accounts easily, and each 40 | account has its own profile, posts, and settings. 41 | 42 | 43 | Hollo is headless; instead, it complies with the [Mastodon API], 44 | so that you can use any Mastodon-compatible client to interact with it. 45 | 46 | [Mastodon API]: https://docs.joinmastodon.org/methods/ 47 | 48 | 49 | You can compose your posts in [CommonMark] (so-called Markdown), 50 | and Hollo will render them for you—of course, other fediverse software 51 | will render them as well. Moreover, you're allowed up to 10,000 characters 52 | per post. 53 | 54 | [CommonMark]: https://commonmark.org/ 55 | 56 | 57 | Hollo supports [Misskey]-style quotes, so you can quote other posts 58 | in your own posts. It's compatible with other fediverse software that 59 | supports Misskey-style quotes, e.g., Misskey, [Akkoma], [Fedibird], 60 | [Threads]. 61 | 62 | [Misskey]: https://misskey-hub.net/ 63 | [Akkoma]: https://akkoma.social/ 64 | [Fedibird]: https://github.com/fedibird/mastodon 65 | [Threads]: https://www.threads.net/ 66 | 67 | 68 | Hollo supports [Misskey]-style emoji reactions, so you can react to posts 69 | with Unicode emojis and custom emojis. It's compatible with other fediverse 70 | software that supports Misskey-style emoji reactions, e.g., Misskey, 71 | [Akkoma]. 72 | 73 | [Misskey]: https://misskey-hub.net/ 74 | [Akkoma]: https://akkoma.social/ 75 | 76 | 77 | Hollo is powered by [Fedify], an ActivityPub server framework for TypeScript. 78 | 79 | [Fedify]: https://fedify.dev/ 80 | 81 | 82 | -------------------------------------------------------------------------------- /docs/src/content/docs/install/docker.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploy using Docker 3 | description: How to deploy Hollo using Docker. 4 | --- 5 | 6 | Hollo provides the official Docker images on [GitHub Packages]. You can use 7 | them to deploy Hollo on your server or your local machine: 8 | 9 | ~~~~ sh frame="none" 10 | docker pull ghcr.io/fedify-dev/hollo:latest 11 | ~~~~ 12 | 13 | To run Hollo, you need to set up a PostgreSQL database and 14 | an S3-compatible object storage for media storage. You can use the 15 | official Docker image for [PostgreSQL], and you can use 16 | [MinIO] for the S3-compatible object storage. Or you can use other 17 | managed services like AWS [RDS], [ElastiCache], and [S3]. 18 | 19 | To connect Hollo to these services, you need to set the environment 20 | variables through `docker run` command's [`-e`/`--env` option or 21 | `--env-file` option][1]. To list the environment variables that Hollo 22 | supports, see the [*Environment variables*](/install/env) chapter. 23 | 24 | [GitHub Packages]: https://github.com/fedify-dev/hollo/pkgs/container/hollo 25 | [PostgreSQL]: https://hub.docker.com/_/postgres 26 | [MinIO]: https://hub.docker.com/r/minio/minio 27 | [RDS]: https://aws.amazon.com/rds/ 28 | [ElastiCache]: https://aws.amazon.com/elasticache/ 29 | [S3]: https://aws.amazon.com/s3/ 30 | [1]: https://docs.docker.com/reference/cli/docker/container/run/#env 31 | 32 | 33 | Docker Compose 34 | -------------- 35 | 36 | import { Code } from "@astrojs/starlight/components"; 37 | import composeYaml from "./docker/compose-yaml?raw"; 38 | 39 | To wire up these services, you can use [Docker Compose]. Here's an example 40 | *compose.yaml* file: 41 | 42 | 43 | 44 | Save this file as *compose.yaml* in your working directory, and then run 45 | the following command: 46 | 47 | ~~~~ sh frame="none" 48 | docker compose up -d 49 | ~~~~ 50 | 51 | [Docker Compose]: https://docs.docker.com/compose/ 52 | -------------------------------------------------------------------------------- /docs/src/content/docs/install/docker/compose-yaml: -------------------------------------------------------------------------------- 1 | ../../../../../../compose.yaml -------------------------------------------------------------------------------- /docs/src/content/docs/install/railway.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploy to Railway 3 | description: How to deploy Hollo on Railway. 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | [![Deploy on Railway][]][Railway template] 9 | 10 | The easiest way to deploy Hollo is to use [Railway]. Railway is a platform that 11 | allows you to deploy your apps with ease. It supports a variety of languages and 12 | frameworks, including Node.js, Python, Ruby, and more. 13 | 14 | Click the button above to deploy Hollo on Railway. With this template, you can 15 | get started with your own Hollo in just a few clicks. 16 | 17 | To deploy Hollo, you need S3 or S3-compatible object storage for storing media 18 | such as images. There are many S3-compatible object storage services, 19 | including AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, and Linode Object 20 | Storage. Once you have your object storage ready, you'll need to configure 21 | the environment variables appropriately (see how to use the S3 client API 22 | for each service). For more information, see the 23 | [*Environmnet variables*](/install/env) chapter. 24 | 25 | Once you've set up your environment variables and Hollo is deployed on Railway, 26 | go to https://yourdomain/setup to set up your login credentials and add your 27 | profile. 28 | 29 | 34 | 35 | Once you've created your profile, you're ready to start enjoying Hollo. 36 | It's worth noting that Hollo doesn't have much of a web interface of its own, 37 | so you'll need to use a client app like [Phanpy] for now. 38 | 39 | [Railway]: https://railway.app/ 40 | [Deploy on Railway]: https://railway.app/button.svg 41 | [Railway template]: https://railway.app/template/eopPyH?referralCode=qeEK5G 42 | [Phanpy]: https://phanpy.social/ 43 | 44 | 45 | Upgrading 46 | --------- 47 | 48 | import { Steps } from "@astrojs/starlight/components"; 49 | 50 | To upgrade Hollo, just redeploy the service on Railway: 51 | 52 | 53 | 1. Go to the Railway dashboard. 54 | 55 | 2. Choose your Hollo project. 56 | 57 | 3. Choose your Hollo service. 58 | 59 | ![Choose your Hollo service](./railway/project.png) 60 | 61 | 4. In the deployments, click the button in the right corner which looks 62 | like three vertical dots. 63 | 64 | 5. In the dropdown, click *Redeploy* to redeploy the service. 65 | 66 | ![Redeploy the service](./railway/deployments.png) 67 | 68 | -------------------------------------------------------------------------------- /docs/src/content/docs/install/railway/deployments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/railway/deployments.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/railway/project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/railway/project.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Setting up 3 | description: How to set up Hollo. 4 | --- 5 | 6 | import { Aside, Steps } from "@astrojs/starlight/components"; 7 | 8 | 13 | 14 | After you've installed Hollo, you need to set it up. This guide will walk you 15 | through the process of setting up Hollo on your server. 16 | 17 | 18 | 1. Go to **https://yourdomain/setup** where yourdomain is 19 | your domain name. 20 | 21 | 2. Set up your login credentials. 22 | 23 | ![Set up your login credentials](./setup/setup.png) 24 | 25 | 3. You'll see the empty *Accounts* page. Click the *Create a new account* 26 | button. 27 | 28 | ![Empty accounts page](./setup/accounts-empty.png) 29 | 30 | 4. Fill out the form to create your account. All fields except for the 31 | *Username* field can be changed later. 32 | 33 | ![Create a new account](./setup/create-account.png) 34 | 35 | 5. After you've created your account, you'll see the *Accounts* page with your 36 | account listed. 37 | 38 | ![Accounts page](./setup/accounts.png) 39 | 40 | 6. Since Hollo is headless, you'll need to use a Mastodon-compatible client app 41 | like [Phanpy] to interact with it. Here we use Phanpy as an example. 42 | 43 | Go to **https://phanpy.social/** and click *Log in with Mastodon* button 44 | to start logging in. 45 | 46 | 7. Enter your Hollo domain name and click *Continue*. 47 | 48 | ![Phanpy](./setup/phanpy-login.png) 49 | 50 | 8. At this point, you may be asked to sign in to your Hollo account. Enter 51 | your username and password and click *Sign in*. 52 | 53 | If you're not asked to sign in, skip to the next step. 54 | 55 | 9. You'll see the *Authorize Phanpy* page. Click *Allow* to authorize Phanpy 56 | to access your Hollo account. 57 | 58 | ![Authorize Phanpy](./setup/authorize.png) 59 | 60 | 10. That's it! You're now logged in to your Hollo account using Phanpy. 61 | 62 | The timeline will be empty at first, but you can start posting and 63 | following other users. If you want to follow the official Hollo account, 64 | search for `@hollo@hollo.social` and click the *Follow* button in the 65 | profile page. 66 | 67 | Enojoy! 68 | 69 | ![Phanpy](./setup/phanpy.png) 70 | 71 | [Phanpy]: https://phanpy.social/ 72 | 73 | -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/accounts-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/accounts-empty.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/accounts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/accounts.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/authorize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/authorize.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/create-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/create-account.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/phanpy-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/phanpy-login.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/phanpy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/phanpy.png -------------------------------------------------------------------------------- /docs/src/content/docs/install/setup/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/docs/src/content/docs/install/setup/setup.png -------------------------------------------------------------------------------- /docs/src/content/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: What is Hollo? 3 | description: Hollo is a federated microblogging software for single-users. 4 | --- 5 | 6 | Hollo is a simple, single-user microblogging tool that lets you run your own 7 | little corner of the internet. Think of it as your personal Mastodon instance, 8 | but stripped down to the essentials. 9 | 10 | What makes Hollo stand out is that it's part of the [fediverse]--a network of 11 | interconnected servers running on open protocols, primarily [ActivityPub]. 12 | This means you can connect and interact with folks on other platforms like: 13 | 14 | - [Mastodon] 15 | - [Misskey] 16 | - [Akkoma] 17 | - [WordPress] 18 | - [Threads] 19 | - And many other fediverse servers 20 | 21 | So you get the calm of your own space, but you're still connected to a wider 22 | community. 23 | 24 | [fediverse]: https://www.theverge.com/24063290/fediverse-explained-activitypub-social-media-open-protocol 25 | [ActivityPub]: https://activitypub.rocks/ 26 | [Mastodon]: https://joinmastodon.org/ 27 | [Misskey]: https://misskey-hub.net/ 28 | [Akkoma]: https://akkoma.social/ 29 | [WordPress]: https://wordpress.org/ 30 | [Threads]: https://www.threads.net/ 31 | 32 | 33 | Features 34 | -------- 35 | 36 | Hollo boasts feature parity with Mastodon for most individual user needs. 37 | You get all the core microblogging features you'd expect, minus the stuff that 38 | doesn't make sense for a single-user instance (like moderation tools). 39 | This means you can: 40 | 41 | - Use hashtags and mentions 42 | - Share media attachments 43 | - Create polls 44 | - Share and favorite posts 45 | - Misskey-style emoji reactions 46 | 47 | When it comes to writing your posts, Hollo keeps things flexible. You can: 48 | 49 | - Write posts up to 10,000 characters long 50 | - Use [CommonMark] (so-called Markdown) for easy formatting 51 | - Use Misskey-style quotes to add some flair to your posts 52 | 53 | Hollo is what we call "headless," which is a techy way of saying it doesn't have 54 | its own web interface. Instead, you can use any Mastodon-compatible app to post 55 | and interact. This might sound odd at first, but it actually gives you 56 | the freedom to pick an app that suits your style. 57 | 58 | In essence, Hollo is a straightforward tool that brings the power of Mastodon 59 | or Misskey 60 | to individual users. It helps you share thoughts online--from quick updates to 61 | longer musings--connect with others in the fediverse, and do it all on your own 62 | terms. It's not aiming to be the next big social network--it's just here to 63 | make microblogging a bit easier and more connected. 64 | 65 | [CommonMark]: https://commonmark.org/ 66 | 67 | 68 | License 69 | ------- 70 | 71 | Hollo is distributed under the terms of the [GNU Affero General Public License 72 | version 3][AGPLv3] or later, which means you can use, modify, and distribute it 73 | freely, as long as you keep the source code open and share your changes with 74 | others. 75 | 76 | [AGPLv3]: https://www.gnu.org/licenses/agpl-3.0 77 | 78 | 79 | Etymology 80 | --------- 81 | 82 | The name *Hollo* is from a Korean word *홀로*, which means *alone* or 83 | *solitary*. It is named so because it is designed to be a single-user software. 84 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/clients.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: テスト済みクライアント 3 | description: >- 4 | HolloはMastodon APIを実装したヘッドレスマイクロブログソフトウェアです。 5 | これは理論的にはHolloと相互作用できるMastodon互換クライアントであれば何でも使用できることを意味します。Holloでテストされたクライアントをここに列挙します。 6 | --- 7 | 8 | import { Badge } from '@astrojs/starlight/components'; 9 | 10 | HolloはMastodon APIを実装したヘッドレスマイクロブログソフトウェアです。 11 | これは理論的にはHolloと相互作用できるMastodon互換クライアントであれば何でも使用できることを意味します。 12 | しかし、実際にはMastodon APIを実装する方法の違いにより、一部のクライアントが予想どおりに動作しないことがあります。Holloでテストされたクライアントをここに列挙します。 13 | 14 | 15 | ウェブ 16 | ------ 17 | 18 | ### [Elk](https://elk.zone/) 19 | 20 | - 引用は書き込みのみ。[^1] 21 | - 絵文字リアクション未対応。 22 | 23 | [^1]: 新しい投稿を書く時、引用する投稿のリンクをペーストする事で引用可能。 24 | 25 | ### [Phanpy](https://phanpy.social/) 26 | 27 | - 絵文字リアクションは見るだけ。 28 | 29 | 30 | Android 31 | ------- 32 | 33 | ### [Moshidon](https://lucasggamerm.github.io/moshidon/) 34 | 35 | - 絵文字リアクションを使うには、*設定* → *インスタンス* → 36 | *絵文字リアクションを有効にする* オプションをオンにする必要が有る。 37 | - CommonMarkを使うには、*設定* → *インスタンス* → 38 | *投稿フォーマットを有効化* オプションをオンにし、 39 | *デフォルトのコンテンツタイプ* を *Markdown* に設定する必要が有る。 40 | 41 | ### [Subway Tooter](https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter) 42 | 43 | - 引用は書き込みのみ。[^1] 44 | 45 | 46 | iOS 47 | --- 48 | 49 | ### [Mona](https://getmona.app/) 50 | 51 | - 絵文字リアクション未対応。 52 | 53 | ### [Nightfox DAWN](https://noppe.dev/dawn) 54 | 55 | - 引用するには、引用する投稿のリンクをペーストする必要が有る。[^1] 56 | 57 | ### [Tusker](https://vaccor.space/tusker/) 58 | 59 | - 引用は書き込みのみ。[^1] 60 | - 絵文字リアクション未対応。 61 | - 日本語未対応。 62 | 63 | ### [Woolly](https://apps.apple.com/app/woolly-for-mastodon/id6444360628) 64 | 65 | - 引用は書き込みのみ。[^1] 66 | - 絵文字リアクション未対応。 67 | - 日本語未対応。 68 | 69 | ---- 70 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: ようこそHolloへ! 3 | description: >- 4 | Holloは1人用の連合マイクロブログソフトウェアです。 5 | template: splash 6 | hero: 7 | tagline: >- 8 | 1人用の連合マイクロブログソフトウェア。 9 | ActivityPub、Mastodon互換API、CommonMark、Misskeyスタイルの引用をサポート。 10 | image: 11 | light: ../../../assets/logo-black.svg 12 | dark: ../../../assets/logo-white.svg 13 | actions: 14 | - text: Holloとは? 15 | link: /ja/intro 16 | icon: right-arrow 17 | variant: secondary 18 | - text: インストール 19 | link: /ja/install/railway 20 | icon: rocket 21 | variant: primary 22 | - text: 公式インスタンス 23 | link: https://hollo.social/@hollo 24 | icon: external 25 | variant: minimal 26 | --- 27 | 28 | import { Card, CardGrid } from "@astrojs/starlight/components"; 29 | 30 | 31 | 32 | Holloは1人用に設計されているため、 33 | インスタンスを所有し、データに完全な制御権を持つことができます。 34 | 個人用のマイクロブログ、ノート、日記に最適です。 35 | 36 | 37 | Holloは1人用に設計されていますが、 38 | 同じインスタンスで複数のアカウントを作ることができます。 39 | アカウント間の切り替えも簡単で、 40 | 各アカウントにはそれぞれ別のプロフィール、投稿、設定があります。 41 | 42 | 43 | Holloには独自のウェブインターフェースがありません。 44 | 代わりに、[Mastodon API]と互換性があり、 45 | どんなMastodon互換クライアントアプリからも 46 | Holloを利用できます。 47 | 48 | [Mastodon API]: https://docs.joinmastodon.org/methods/ 49 | 50 | 51 | [CommonMark](通称Markdown)で投稿を作成できます。 52 | そのようにして作成した投稿は、 53 | フェディバース(fediverse)の他のソフトウェアでもうまく表示されます。 54 | また、最大10,000文字の投稿を作成できます。 55 | 56 | [CommonMark]: https://commonmark.org/ 57 | 58 | 59 | Holloは[Misskey]スタイルの引用機能をサポートしているため、 60 | 他の投稿を引用して自分の投稿に含めることができます。 61 | これはMisskey、[Akkoma]、[Fedibird]、[Threads]など、 62 | Misskeyスタイルの引用をサポートする他のフェディバースソフトウェアとも互換性があります。 63 | 64 | [Misskey]: https://misskey-hub.net/ja/ 65 | [Akkoma]: https://akkoma.social/ 66 | [Fedibird]: https://github.com/fedibird/mastodon 67 | [Threads]: https://www.threads.net/ 68 | 69 | 70 | Holloは[Misskey]スタイルの絵文字リアクション機能をサポートしているため、 71 | 他の投稿に絵文字リアクションを追加することができます。 72 | これはMisskey、[Akkoma]など、 73 | Misskeyスタイルの絵文字リアクションをサポートする他のフェディバースソフトウェアとも互換性があります。 74 | 75 | [Misskey]: https://misskey-hub.net/ja/ 76 | [Akkoma]: https://akkoma.social/ 77 | 78 | 79 | HolloはTypeScript向けのActivityPubサーバーフレームワークである[Fedify]で作られています。 80 | 81 | [Fedify]: https://fedify.dev/ 82 | 83 | 84 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/install/docker.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dockerでデプロイ 3 | description: HolloをDockerでデプロイする方法を説明します。 4 | --- 5 | 6 | Holloは[GitHub Packages]で公式のDockerイメージを提供しています。 7 | そのイメージをローカルマシンでHolloをデプロイできます: 8 | 9 | ~~~~ sh frame="none" 10 | docker pull ghcr.io/fedify-dev/hollo:latest 11 | ~~~~ 12 | 13 | Holloを実行するには、PostgreSQLのデータベースとメディアを保存するためのS3互換のオブジェクトストレージが必要です。 14 | [PostgreSQL]の公式のDockerイメージとS3互換のオブジェクトストレージである[MinIO]を使うことができます。 15 | またはAWSの[RDS]、[ElastiCache]、[S3]などのマネージドサービスを使用することもできます。 16 | 17 | Holloにこれらのサービスを組み合わせるには、 18 | `docker run`コマンドの[`-e`/`--env`オプションまたは`--env-file`オプション][1]を使用して 19 | 環境変数を設定する必要があります。 20 | Holloがサポートする環境変数のリストは[**環境変数**](/ja/install/env)章で確認できます。 21 | 22 | [GitHub Packages]: https://github.com/fedify-dev/hollo/pkgs/container/hollo 23 | [PostgreSQL]: https://hub.docker.com/_/postgres 24 | [MinIO]: https://hub.docker.com/r/minio/minio 25 | [RDS]: https://aws.amazon.com/rds/ 26 | [ElastiCache]: https://aws.amazon.com/elasticache/ 27 | [S3]: https://aws.amazon.com/s3/ 28 | [1]: https://docs.docker.com/reference/cli/docker/container/run/#env 29 | 30 | 31 | Docker Compose 32 | -------------- 33 | 34 | import { Code } from "@astrojs/starlight/components"; 35 | import composeYaml from "../../install/docker/compose-yaml?raw"; 36 | 37 | PostgreSQLとS3互換のオブジェクトストレージなどをHolloに接続してデプロイするために、 38 | [Docker Compose]を使用することができます。以下は*compose.yaml*ファイルの例です: 39 | 40 | 41 | 42 | 上記のファイルを作業ディレクトリに*compose.yaml*として保存し、次のコマンドを実行します: 43 | 44 | ~~~~ sh frame="none" 45 | docker compose up -d 46 | ~~~~ 47 | 48 | [Docker Compose]: https://docs.docker.com/compose/ 49 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/install/manual.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 手動インストール 3 | description: Holloを手動でインストールする方法を説明します。 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | Holloは手動でインストールすることもできます。 9 | このマニュアルでは、Holloを手動でインストールする手順を説明します。 10 | ただし、すでにウェブアプリケーションの運用経験があり、コマンドラインに慣れていることを前提としています。 11 | 12 | 19 | 20 | 21 | 前提条件 22 | -------- 23 | 24 | 始める前に、サーバーに以下のソフトウェアがインストールされていることを確認してください: 25 | 26 | - [Git] 27 | - [Node.js] 24+ 28 | - [pnpm] 29 | - [ffmpeg] 30 | - [PostgreSQL] 17+ 31 | - L7ロードバランサー(例:[nginx]、[Caddy]) 32 | - あなたのサーバーを指しているドメイン名 33 | 34 | [Git]: https://git-scm.com/ 35 | [Node.js]: https://nodejs.org/ 36 | [pnpm]: https://pnpm.io/ 37 | [ffmpeg]: https://www.ffmpeg.org/ 38 | [PostgreSQL]: https://www.postgresql.org/ 39 | [nginx]: https://nginx.org/ 40 | [Caddy]: https://caddyserver.com/ 41 | 42 | 43 | インストール 44 | ------------ 45 | 46 | import { Steps } from "@astrojs/starlight/components"; 47 | 48 | 49 | 1. Hollonの最新コードを[GitHub]から取得します: 50 | 51 | ~~~~ sh frame="none" 52 | git clone -b stable https://github.com/fedify-dev/hollo.git 53 | cd hollo/ 54 | ~~~~ 55 | 56 | 2. pnpmで依存関係をインストールします: 57 | 58 | ~~~~ sh frame="none" 59 | pnpm install 60 | ~~~~ 61 | 62 | 3. HolloのためにPostgreSQLユーザーとデータベースを作成します: 63 | 64 | ~~~~ sh frame="none" 65 | createuser --createdb --pwprompt hollo 66 | createdb --username=hollo --encoding=utf8 --template=postgres hollo 67 | ~~~~ 68 | 69 | 4. Holloの設定ファイルを作成します: 70 | 71 | ~~~~ sh frame="none" 72 | cp .env.sample .env 73 | ~~~~ 74 | 75 | [GitHub]: https://github.com/fedify-dev/hollo 76 | 77 | 78 | 79 | 設定 80 | ---- 81 | 82 | Holloをインストールしたら、設定を行う必要があります。 83 | 先に作った*.env*ファイルを開いて環境変数の値を適切に変更してください。 84 | 85 | [**環境変数**](/ja/install/env)の章でより詳しい内容を確認することができます。 86 | 87 | 88 | サーバー起動 89 | ------------ 90 | 91 | サーバーを起動する準備が整ったら、以下のコマンドを実行します: 92 | 93 | ~~~~ sh frame="none" 94 | pnpm run prod 95 | ~~~~ 96 | 97 | 98 | アップデート 99 | ------------ 100 | 101 | Holloをアップデートするには、 102 | GitHubリポジトリから最新のコードを取得し、依存関係を再インストールするだけです。 103 | 104 | 105 | 1. GitHubリポジトリから最新のHolloコードを取得します: 106 | 107 | ~~~~ sh frame="none" 108 | git pull 109 | ~~~~ 110 | 111 | 2. pnpmで依存関係を再インストールします: 112 | 113 | ~~~~ sh frame="none" 114 | pnpm install 115 | ~~~~ 116 | 117 | 3. サーバーを再起動します: 118 | 119 | ~~~~ sh frame="none" 120 | pnpm run prod 121 | ~~~~ 122 | 123 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/install/railway.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Railwayにデプロイ 3 | descriptipn: HolloをRailwayにデプロイする方法を説明します。 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | [![Deploy on Railway][]][Railway template] 9 | 10 | Holloをデプロイする最も簡単な方法は[Railway]を使うことです。 11 | Railwayはサーバーアプリを簡単にデプロイできるプラットフォームで、Node.js、Python、Rubyなど多くの言語とフレームワークをサポートしています。 12 | 13 | 上にある**Deploy on Railway**ボタンを押すと、RailwayにHolloをデプロイできます。 14 | このテンプレートを使うと、Holloをデプロイするのに必要なすべてが数回のクリックで自動的に設定されます。 15 | 16 | Holloをデプロイするには、画像などのメディアを保存するS3やS3互換のオブジェクトストレージが必要です。 17 | S3互換のオブジェクトストレージには、AWS S3、Cloudflare R2、MinIO、DigitalOcean 18 | Spaces、Linode Object Storageなどがあります。 19 | オブジェクトストレージの準備ができたら、環境変数を適切に設定する必要があります。 20 | (各サービスのS3クライアントAPIの使い方を参照してください) 21 | 詳しくは[**環境変数**](/ja/install/env)の章を参照してください。 22 | 23 | 環境変数を設定し、HolloがRailwayにデプロイされたら、 24 | https://yourdomain/setup 25 | ​(yourdomainは各自のドメインに置き換えてください) 26 | にアクセスしてログイン情報を設定し、プロフィールを追加してください。 27 | 28 | 32 | 33 | プロファイルを作成したら、Holloを使う準備が整いました。 34 | ちなみに、Holloは独自のウェブインターフェースがほとんどないので、 35 | 現時点では[Phanpy]のようなクライアントアプリを使う必要があります。 36 | 37 | [Railway]: https://railway.app/ 38 | [Deploy on Railway]: https://railway.app/button.svg 39 | [Railway template]: https://railway.app/template/eopPyH?referralCode=qeEK5G 40 | [Phanpy]: https://phanpy.social/ 41 | 42 | 43 | アップデート方法 44 | ---------------- 45 | 46 | import { Steps } from "@astrojs/starlight/components"; 47 | 48 | Holloをアップデートするためには、Railwayにあるサービスを再デプロイするだけです: 49 | 50 | 51 | 1. Railwayのダッシュボードにログインしてください。 52 | 53 | 2. リストからHolloプロジェクトを選択してください。 54 | 55 | 3. サービスリストからHolloを選んでください。 56 | 57 | ![サービスリストからHolloを選んでください](../../install/railway/project.png) 58 | 59 | 4. Deploymentsタブの右隅にある縦に並んだ三つの点をクリックします。 60 | 61 | 5. 開かれたドロップダウンメニューから、**Redeploy**を押してHolloを再デプロイします。 62 | 63 | ![サービスの再デプロイ](../../install/railway/deployments.png) 64 | 65 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/install/setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 初期設定 3 | description: Holloをインストールした後、セットアップ方法を説明します。 4 | --- 5 | 6 | import { Aside, Steps } from "@astrojs/starlight/components"; 7 | 8 | 12 | 13 | Holloをインストールした後、初期設定を行う必要があります。 14 | このガイドでは、Holloの設定手順を説明します。 15 | 16 | 17 | 1. **https://yourdomain/setup**にアクセスします。 18 | ここで、yourdomainはあなたのドメイン名です。 19 | 20 | 2. ログインに使用するメールアドレスとパスワードを設定します。 21 | 22 | ![メールアドレスとパスワードを設定](../../install/setup/setup.png) 23 | 24 | 3. 空のAccountsページが表示されます。 25 | **Create a new account**ボタンをクリックします。 26 | 27 | ![空のAccountsページ](../../install/setup/accounts-empty.png) 28 | 29 | 4. アカウントを作成するためにフォームを入力します。**Username**フィールド以外の項目は後で変更できます。 30 | 31 | ![アカウント作成](../../install/setup/create-account.png) 32 | 33 | 5. アカウントを作成すると、Accountsページに作成したアカウントが表示されます。 34 | 35 | ![Accountsページ](../../install/setup/accounts.png) 36 | 37 | 6. Holloには独自のウェブインターフェースがないため、 38 | [Phanpy]などのMastodon互換クライアントアプリを選んで使用する必要があります。 39 | ここではPhanpyを例に説明します。 40 | 41 | **https://phanpy.social/** にアクセスし、 42 | **Mastodonにログイン**ボタンをクリックします。 43 | 44 | 7. Holloインスタンスのドメイン名を入力し、**ログイン**ボタンをクリックします。 45 | 46 | ![Phanpy](../../install/setup/phanpy-login.png) 47 | 48 | 8. この時、Holloアカウントでログインするように求められるかもしれません。 49 | その場合は、先ほど設定したメールアドレスとパスワードを入力し、 50 | **Sign in**ボタンをクリックします。 51 | 52 | ログインを求められない場合は、次のステップに進んでください。 53 | 54 | 9. **Authorized Phanpy**ページが表示されます。 55 | **Allow**ボタンをクリックして、PhanpyがHolloアカウントにアクセスできるように許可します。 56 | 57 | ![Authorize Phanpy](../../install/setup/authorize.png) 58 | 59 | 10. これで完了です!Phanpyを使用してHolloアカウントにログインしました。 60 | 61 | タイムラインは初めてなので空ですが、 62 | 投稿を行い他のユーザーをフォローすることができます。 63 | Hollo公式アカウントをフォローしたい場合は、 64 | `@hollo@hollo.social`を検索してプロフィールページから**フォロー**ボタンをクリックしてください。 65 | 66 | では、Holloをお楽しみください! 67 | 68 | ![Phanpy](../../install/setup/phanpy.png) 69 | 70 | [Phanpy]: https://phanpy.social/ 71 | 72 | -------------------------------------------------------------------------------- /docs/src/content/docs/ja/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Holloとは? 3 | description: Holloは1人用の連合マイクロブログソフトウェアです。 4 | --- 5 | 6 | Holloはあなただけの小さなインターネット空間を作ることができる簡単な1人用マイクロブログです。 7 | 個人用Mastodonのインスタンスと考えてもいいですが、本当に必要な機能だけで構成されています。 8 | 9 | Holloの特徴は、[フェディバース](fediverse)の一部であることです。 10 | フェディバースとは、[ActivityPub]プロトコルを介して相互に接続されたサーバーのネットワークを意味します。 11 | つまり、Holloを使用すると、次のような他のフェディバースのプラットフォームのユーザーと接続することができます: 12 | 13 | - [Mastodon] 14 | - [Misskey] 15 | - [Akkoma] 16 | - [WordPress] 17 | - [Threads] 18 | - その他、様々なフェディバースサーバー 19 | 20 | そのため、あなただけの静かな空間を持ちながら、広いコミュニティとつながることができます。 21 | 22 | [フェディバース]: https://ja.wikipedia.org/wiki/Fediverse 23 | [ActivityPub]: https://activitypub.rocks/ 24 | [Mastodon]: https://joinmastodon.org/ko 25 | [Misskey]: https://misskey-hub.net/ko/ 26 | [Akkoma]: https://akkoma.social/ 27 | [WordPress]: https://ko.wordpress.org/ 28 | [Threads]: https://www.threads.net/ 29 | 30 | 31 | 機能 32 | ---- 33 | 34 | Holloは、ほとんどの個人ユーザーが必要とするものについて、Mastodonと同等の機能を提供します。 35 | 期待できる主要なマイクロブログ機能をすべて利用できる一方で、 36 | (モデレーションツールなど)1人ユーザーインスタンスには必要ない機能は除外されています。 37 | つまり、次のようなことができます: 38 | 39 | - ハッシュタグとメンション 40 | - メディアの添付 41 | - アンケートの作成 42 | - 投稿の共有とお気に入り 43 | - Misskeyスタイルの絵文字のリアクション 44 | 45 | 一方、投稿の作成については、Holloは非常に豊富な機能を提供しています: 46 | 47 | - 最大10,000文字の投稿作成 48 | - [CommonMark]文法(通称Markdown)をサポート 49 | - Misskeyスタイルの引用 50 | 51 | Holloは、独自のウェブインタフェースを持たない「ヘッドレス」(headless)ソフトウェアです。 52 | 代わりに、任意のMastodon互換のクライアントアプリでHolloを使用することができます。 53 | 最初は奇妙に思えるかもしれませんが、これにより、 54 | 最も馴染みのあるアプリでHolloを使用することができます。 55 | 56 | 要するに、HolloはMastodonやMisskeyの機能を個人ユーザーがそのまま享受できる簡単なツールです。 57 | 短い近況の共有から長い思索まで、オンラインであなたの考えを共有して、 58 | フェディバースの他の人々とつながり、 59 | あなただけの方法でこのすべてを行うことができます。 60 | 次世代の巨大ソーシャルネットワークではなく、マイクロブログをより簡単で居心地の良いものにするために作られました。 61 | 62 | [CommonMark]: https://commonmark.org/ 63 | 64 | ライセンス 65 | ---------- 66 | 67 | Holloは、[GNUアフェロ一般公衆ライセンスのバージョン3][AGPLv3]およびそれ以降のバージョンで配布されています。 68 | つまり、ソースコードを公開し、変更内容を他の人と共有する限り、自由に使用、変更、および配布できます。 69 | 70 | [AGPLv3]: https://www.gnu.org/licenses/agpl-3.0.ja.html 71 | 72 | 73 | 語源 74 | ---- 75 | 76 | Holloという名前は、韓国語で「一人で」という意味の「홀로」(ホロ) 77 | という単語に由来しています。1人用のソフトウェアであるため、このような名前が付けられました。 78 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/clients.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 테스트된 클라이언트 3 | description: >- 4 | Hollo는 Mastodon API를 구현한 헤들리스 마이크로블로깅 5 | 소프트웨어입니다. 이는 이론적으로는 Hollo와 상호작용할 수 있는 6 | Mastodon 호환 클라이언트라면 무엇이든 사용할 수 있다는 것을 7 | 의미합니다. Hollo에서 테스트된 클라이언트를 여기에 나열합니다. 8 | --- 9 | 10 | import { Badge } from '@astrojs/starlight/components'; 11 | 12 | Hollo는 Mastodon API를 구현한 헤들리스 마이크로블로깅 13 | 소프트웨어입니다. 이는 이론적으로는 Hollo와 상호작용할 수 있는 14 | Mastodon 호환 클라이언트라면 무엇이든 사용할 수 있다는 것을 15 | 의미합니다. 그러나 실제로는 Mastodon API를 구현하는 방식의 차이로 16 | 인해 일부 클라이언트가 예상대로 작동하지 않을 수 있습니다. 17 | Hollo에서 테스트된 클라이언트를 여기에 나열합니다. 18 | 19 | 20 | 웹 21 | --- 22 | 23 | ### [Elk](https://elk.zone/) 24 | 25 | - 인용은 쓰기만 가능.[^1] 26 | - 에모지 리액션 미지원. 27 | 28 | [^1]: 새 게시물을 쓸 때 인용할 게시물의 링크를 붙여는 것으로 인용 가능. 29 | 30 | ### [Phanpy](https://phanpy.social/) 31 | 32 | - 에모지 리액션은 보는 것만 가능. 33 | 34 | 35 | Android 36 | ------- 37 | 38 | ### [Moshidon](https://lucasggamerm.github.io/moshidon/) 39 | 40 | - 에모지 리액션을 쓰려면 *설정* → *인스턴스* → *이모지 반응 활성화* 41 | 옵션을 켜야 함. 42 | - CommonMark를 쓰려면 *설정* → *인스턴스* → *게시물 서식 활성화* 43 | 옵션을 켜고, *기본 콘텐츠 유형*을 *Markdown*으로 설정해야 함. 44 | 45 | ### [Subway Tooter](https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter) 46 | 47 | - 인용은 쓰기만 가능.[^1] 48 | 49 | 50 | iOS 51 | --- 52 | 53 | ### [Mona](https://getmona.app/) 54 | 55 | - 에모지 리액션 미지원. 56 | 57 | ### [Nightfox DAWN](https://noppe.dev/dawn) 58 | 59 | - 인용하려면 인용할 게시물의 링크를 붙여야 함.[^1] 60 | - 한국어 미지원. 61 | 62 | ### [Tusker](https://vaccor.space/tusker/) 63 | 64 | - 인용은 쓰기만 가능.[^1] 65 | - 에모지 리액션 미지원. 66 | - 한국어 미지원. 67 | 68 | ### [Woolly](https://apps.apple.com/app/woolly-for-mastodon/id6444360628) 69 | 70 | - 인용은 쓰기만 가능.[^1] 71 | - 에모지 리액션 미지원. 72 | - 한국어 미지원. 73 | 74 | ---- 75 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hollo에 어서 오세요! 3 | description: >- 4 | Hollo는 1인 사용자용 연합 마이크로블로그 소프트웨어입니다. 5 | template: splash 6 | hero: 7 | tagline: >- 8 | 1인 사용자용 연합 마이크로블로그 소프트웨어. 9 | ActivityPub, Mastodon 호환 API, CommonMark 및 Misskey 스타일 인용 지원. 10 | image: 11 | light: ../../../assets/logo-black.svg 12 | dark: ../../../assets/logo-white.svg 13 | actions: 14 | - text: Hollo란? 15 | link: /ko/intro 16 | icon: right-arrow 17 | variant: secondary 18 | - text: 설치 19 | link: /ko/install/railway 20 | icon: rocket 21 | variant: primary 22 | - text: 공식 인스턴스 23 | link: https://hollo.social/@hollo 24 | icon: external 25 | variant: minimal 26 | --- 27 | 28 | import { Card, CardGrid } from "@astrojs/starlight/components"; 29 | 30 | 31 | 32 | Hollo는 1인 사용자용으로 설계되어 있어, 33 | 인스턴스를 스스로 소유하고 데이터에 대한 제어권을 완전히 가질 수 있습니다. 34 | 개인용 마이크로블로그, 노트, 일기 등에 안성맞춤입니다. 35 | 36 | 37 | 비록 Hollo는 1인 사용자용으로 설계되어 있지만, 38 | 한 인스턴스 안에서 여러 계정을 만드는 것이 가능합니다. 39 | 계정 간 전환도 쉽고, 각 계정은 자신만의 프로필, 게시물, 설정을 갖습니다. 40 | 41 | 42 | Hollo는 자체 웹 인터페이스가 없습니다. 43 | 대신, [Mastodon API]와 호환되어, 아무 Mastodon 호환 클라이언트 앱으로 44 | Hollo를 이용할 수 있습니다. 45 | 46 | [Mastodon API]: https://docs.joinmastodon.org/methods/ 47 | 48 | 49 | [CommonMark](일명 Markdown)으로 게시물을 작성할 수 있으며, 50 | 그렇게 작성한 게시물은 연합우주의 다른 소프트웨어들에서도 잘 보여집니다. 51 | 또한, 게시물 당 최대 10,000자까지 작성할 수 있습니다. 52 | 53 | [CommonMark]: https://commonmark.org/ 54 | 55 | 56 | Hollo는 [Misskey] 스타일의 인용 기능을 지원하므로, 57 | 다른 게시물을 인용하여 자신의 게시물에 포함시킬 수 있습니다. 58 | 이는 Misskey, [Akkoma], [Fedibird], [Threads] 등 Misskey 스타일 인용을 지원하는 59 | 다른 연합우주 소프트웨어들과도 호환됩니다. 60 | 61 | [Misskey]: https://misskey-hub.net/ko/ 62 | [Akkoma]: https://akkoma.social/ 63 | [Fedibird]: https://github.com/fedibird/mastodon 64 | [Threads]: https://www.threads.net/ 65 | 66 | 67 | Hollo는 [Misskey] 스타일의 에모지 리액션을 지원하므로, 68 | 게시물에 Unicode 에모지나 커스텀 에모지를 리액션으로 달 수 있습니다. 69 | 이는 Misskey, [Akkoma] 등 Misskey 스타일 에모지 리액션을 지원하는 70 | 다른 연합우주 소프트웨어들과도 호환됩니다. 71 | 72 | [Misskey]: https://misskey-hub.net/ 73 | [Akkoma]: https://akkoma.social/ 74 | 75 | 76 | Hollo는 TypeScript를 위한 ActivityPub 서버 프레임워크인 [Fedify]로 제작되었습니다. 77 | 78 | [Fedify]: https://fedify.dev/ 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/install/docker.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docker로 배포 3 | description: Hollo를 Docker로 배포하는 방법에 대해 설명합니다. 4 | --- 5 | 6 | Hollo는 [GitHub Packages]에서 공식 Docker 이미지를 제공합니다. 7 | 그 이미지를 여러분의 로컬 머신에서 Hollo를 배포할 수 있습니다: 8 | 9 | ~~~~ sh frame="none" 10 | docker pull ghcr.io/fedify-dev/hollo:latest 11 | ~~~~ 12 | 13 | Hollo를 돌리려면, 14 | PostgreSQL 데이터베이스와 미디어 저장을 위한 S3 호환 오브젝트 스토리지가 필요합니다. 15 | [PostgreSQL] 공식 Docker 이미지와 S3 호환 오브젝트 스토리지인 [MinIO]를 쓸 수 있습니다. 16 | 아니면 AWS [RDS], [ElastiCache], [S3]와 같은 관리형 서비스를 사용할 수도 있습니다. 17 | 18 | Hollo에 해당 서비스들을 연결하려면, 19 | `docker run` 명령의 [`-e`/`--env` 옵션이나 `--env-file` 옵션][1]을 통해 20 | 환경 변수를 설정해야 합니다. Hollo가 지원하는 환경 변수 목록은 21 | [**환경 변수**](/ko/install/env) 챕터에서 확인할 수 있습니다. 22 | 23 | [GitHub Packages]: https://github.com/fedify-dev/hollo/pkgs/container/hollo 24 | [PostgreSQL]: https://hub.docker.com/_/postgres 25 | [MinIO]: https://hub.docker.com/r/minio/minio 26 | [RDS]: https://aws.amazon.com/rds/ 27 | [ElastiCache]: https://aws.amazon.com/elasticache/ 28 | [S3]: https://aws.amazon.com/s3/ 29 | [1]: https://docs.docker.com/reference/cli/docker/container/run/#env 30 | 31 | 32 | Docker Compose 33 | -------------- 34 | 35 | import { Code } from "@astrojs/starlight/components"; 36 | import composeYaml from "../../install/docker/compose-yaml?raw"; 37 | 38 | PostgreSQL과 S3 호환 오브젝트 스토리지 등과 Hollo를 묶어서 배포하기 위해, 39 | [Docker Compose]를 사용할 수도 있습니다. 아래는 *compose.yaml* 파일의 예시입니다: 40 | 41 | 42 | 43 | 위 파일을 작업 디렉터리에 *compose.yaml*로 저장한 다음, 44 | 다음 명령을 실행하면 됩니다: 45 | 46 | ~~~~ sh frame="none" 47 | docker compose up -d 48 | ~~~~ 49 | 50 | [Docker Compose]: https://docs.docker.com/compose/ 51 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/install/manual.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 수동 설치 3 | description: Hollo를 수동으로 설치하는 방법을 설명합니다. 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | Hollo는 수동으로도 설치할 수 있습니다. 9 | 이 설명서는 Hollo를 수동으로 설치하는 과정을 안내합니다. 10 | 다만, 여러분이 이미 웹 애플리케이션을 운영해 본 경험이 있으며 11 | 커맨드라인에 익숙하다는 것을 전제로 합니다. 12 | 13 | 20 | 21 | 22 | 준비물 23 | ------ 24 | 25 | 시작하기에 앞서, 서버에 다음 소프트웨어가 설치되어 있는지 확인하세요: 26 | 27 | - [Git] 28 | - [Node.js] 24+ 29 | - [pnpm] 30 | - [ffmpeg] 31 | - [PostgreSQL] 17+ 32 | - L7 로드 밸런서 (e.g., [nginx], [Caddy]) 33 | - 여러분의 서버를 가리키고 있는 도메인 이름 34 | 35 | [Git]: https://git-scm.com/ 36 | [Node.js]: https://nodejs.org/ 37 | [pnpm]: https://pnpm.io/ 38 | [ffmpeg]: https://www.ffmpeg.org/ 39 | [PostgreSQL]: https://www.postgresql.org/ 40 | [nginx]: https://nginx.org/ 41 | [Caddy]: https://caddyserver.com/ 42 | 43 | 44 | 설치 45 | ---- 46 | 47 | import { Steps } from "@astrojs/starlight/components"; 48 | 49 | 50 | 1. Hollo의 최신 코드를 [GitHub] 저장소에서 받습니다: 51 | 52 | ~~~~ sh frame="none" 53 | git clone -b stable https://github.com/fedify-dev/hollo.git 54 | cd hollo/ 55 | ~~~~ 56 | 57 | 2. pnpm으로 의존성을 설치합니다: 58 | 59 | ~~~~ sh frame="none" 60 | pnpm install 61 | ~~~~ 62 | 63 | 3. Hollo에서 쓸 PostgreSQL 사용자와 데이터베이스를 만듭니다: 64 | 65 | ~~~~ sh frame="none" 66 | createuser --createdb --pwprompt hollo 67 | createdb --username=hollo --encoding=utf8 --template=postgres hollo 68 | ~~~~ 69 | 70 | 4. Hollo의 설정 파일을 만듭니다: 71 | 72 | ~~~~ sh frame="none" 73 | cp .env.sample .env 74 | ~~~~ 75 | 76 | [GitHub]: https://github.com/fedify-dev/hollo 77 | 78 | 79 | 80 | 설정 81 | ---- 82 | 83 | Hollo가 설치되었다면, 설정을 해야 합니다. 84 | 앞서 만든 *.env* 파일을 열어 환경 변수의 값들을 적절하게 변경해 주세요. 85 | 86 | [**환경 변수**](/ko/install/env) 챕터에서 좀 더 자세한 내용을 확인할 수 있습니다. 87 | 88 | 89 | 서버 시작하기 90 | ------------- 91 | 92 | 서버를 시작하려면 다음 명령을 실행하세요: 93 | 94 | ~~~~ sh frame="none" 95 | pnpm run prod 96 | ~~~~ 97 | 98 | 99 | 업데이트하기 100 | ------------ 101 | 102 | Hollo를 업데이트하려면, 103 | GitHub 저장소에서 최신 코드를 받고 의존성을 다시 설치하면 됩니다. 104 | 105 | 106 | 1. GitHub 저장소에서 Hollo 최신 코드를 받습니다: 107 | 108 | ~~~~ sh frame="none" 109 | git pull 110 | ~~~~ 111 | 112 | 2. 의존성을 재설치합니다: 113 | 114 | ~~~~ sh frame="none" 115 | pnpm install 116 | ~~~~ 117 | 118 | 3. 서버를 다시 시작합니다: 119 | 120 | ~~~~ sh frame="none" 121 | pnpm run prod 122 | ~~~~ 123 | 124 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/install/railway.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Railway에 배포 3 | descriptipn: Hollo를 Railway에 배포하는 방법을 설명합니다. 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | [![Deploy on Railway][]][Railway template] 9 | 10 | Hollo를 배포하는 가장 쉬운 방법은 [Railway]를 쓰는 것입니다. 11 | Railway는 서버 앱을 쉽게 배포할 수 있는 플랫폼인데, 12 | Node.js, Python, Ruby 등 여러 언어와 프레임워크를 지원합니다. 13 | 14 | 위에 있는 **Deploy on Railway** 버튼을 누르면 Railway에 Hollo를 배포할 수 있습니다. 15 | 이 템플릿을 사용하면 Hollo를 배포하는 데 필요한 모든 것이 클릭 몇 번으로 자동으로 설정됩니다. 16 | 17 | Hollo를 배포하려면 이미지 등의 미디어를 저장할 S3나 S3 호환 오브젝트 스토리지가 필요합니다. 18 | S3 호환 오브젝트 스토리지로는 AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, 19 | Linode Object Storage 등 여러가지가 있습니다. 20 | 여러분의 오브젝트 스토리지가 준비되었다면, 환경 변수를 적절하게 설정해야 합니다 21 | (각 서비스의 S3 클라이언트 API 사용법을 참조하세요). 22 | 더 자세한 정보는 [**환경 변수**](/ko/install/env) 챕터를 참조하세요. 23 | 24 | 환경 변수를 설정하고 Hollo가 Railway에 배포되었다면, 25 | https://yourdomain/setup (yourdomain은 여러분의 도메인으로 치환) 26 | 페이지에서 이메일과 암호를 설정하고 프로필을 추가하세요. 27 | 28 | 32 | 33 | 프로필을 만들었다면, Hollo를 쓸 준비가 되었습니다. 34 | 참고로, Hollo는 자체적인 웹 인터페이스가 거의 없기 때문에, 35 | 현재로서는 [Phanpy]와 같은 클라이언트 앱을 사용해야 합니다. 36 | 37 | [Railway]: https://railway.app/ 38 | [Deploy on Railway]: https://railway.app/button.svg 39 | [Railway template]: https://railway.app/template/eopPyH?referralCode=qeEK5G 40 | [Phanpy]: https://phanpy.social/ 41 | 42 | 43 | 업데이트 방법 44 | ------------- 45 | 46 | import { Steps } from "@astrojs/starlight/components"; 47 | 48 | Hollo를 업데이트하려면, Railway에 있는 서비스를 다시 배포하면 됩니다: 49 | 50 | 51 | 1. Railway 대시보드로 가세요. 52 | 53 | 2. Hollo 프로젝트를 고르세요. 54 | 55 | 3. Hollo 서비스를 고르세요. 56 | 57 | ![Hollo 서비스를 고르세요](../../install/railway/project.png) 58 | 59 | 4. Deployments 탭에서 오른쪽 구석에 있는 세로로 쌓인 세 개의 점을 클릭하세요. 60 | 61 | 5. 펼쳐진 드롭다운 메뉴에서, **Redeploy**를 눌러 Hollo를 다시 배포하세요. 62 | 63 | ![서비스 재배포](../../install/railway/deployments.png) 64 | 65 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/install/setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 설정하기 3 | description: Hollo 설치 후 설정 방법을 설명합니다. 4 | --- 5 | 6 | import { Aside, Steps } from "@astrojs/starlight/components"; 7 | 8 | 13 | 14 | Hollo를 설치한 다음에는 최초 설정을 해야 합니다. 15 | 이 설명서는 Hollo를 설정하는 과정을 안내합니다. 16 | 17 | 18 | 1. **https://yourdomain/setup** 페이지로 이동합니다. 19 | 여기서 yourdomain은 여러분의 도메인 이름입니다. 20 | 21 | 2. 여러분이 로그인할 때 사용할 이메일 주소와 암호를 설정합니다. 22 | 23 | ![이메일 주소와 암호 설정](../../install/setup/setup.png) 24 | 25 | 3. 비어있는 Accounts 페이지가 보입니다. 26 | **Create a new account** 버튼을 클릭합니다. 27 | 28 | ![빈 Accounts 페이지](../../install/setup/accounts-empty.png) 29 | 30 | 4. 계정을 만들기 위해 양식을 채웁니다. **Username** 필드를 뺀 나머지 31 | 항목은 모두 나중에도 변경할 수 있습니다. 32 | 33 | ![계정 생성](../../install/setup/create-account.png) 34 | 35 | 5. 계정을 만들고 나면, Accounts 페이지에 여러분이 만든 계정이 나타납니다. 36 | 37 | ![Accounts 페이지](../../install/setup/accounts.png) 38 | 39 | 6. Hollo는 자체 웹 인터페이스가 없으므로, 40 | [Phanpy] 등과 같은 Mastodon 호환 클라이언트 앱을 하나 골라서 사용하셔야 합니다. 41 | 여기서는 Phanpy를 예로 들어 설명합니다. 42 | 43 | **https://phanpy.social/** 페이지에 접속한 뒤, 44 | **Mastodon으로 로그인** 버튼을 클릭합니다. 45 | 46 | 7. 여러분의 Hollo 인스턴스 도메인 이름을 입력한 뒤, **계속**을 누릅니다. 47 | 48 | ![Phanpy](../../install/setup/phanpy-login.png) 49 | 50 | 8. 이 때, Hollo 계정으로 로그인하라는 요청이 나올 수 있습니다. 51 | 그러면, 아까 초기 설정에서 입력한 이메일 주소와 암호를 입력하고 52 | **Sign in** 버튼을 누릅니다. 53 | 54 | 만약 로그인을 요청받지 않았다면, 다음 단계로 넘어가세요. 55 | 56 | 9. **Authorized Phanpy** 페이지가 보입니다. 57 | **Allow** 버튼을 눌러 Phanpy가 Hollo 계정에 접근할 수 있도록 허용합니다. 58 | 59 | ![Authorize Phanpy](../../install/setup/authorize.png) 60 | 61 | 10. 이제 다 되었습니다! Phanpy를 사용하여 Hollo 계정에 로그인했습니다. 62 | 63 | 타임라인은 처음이라 비어 있긴 하지만, 64 | 게시물을 올리고 다른 사용자를 팔로할 수 있습니다. 65 | Hollo 공식 계정을 팔로하고 싶다면, 66 | `@hollo@hollo.social`을 검색하여 프로필 페이지에서 **팔로** 버튼을 누르세요. 67 | 68 | 그럼, Hollo를 즐기세요! 69 | 70 | ![Phanpy](../../install/setup/phanpy.png) 71 | 72 | [Phanpy]: https://phanpy.social/ 73 | 74 | -------------------------------------------------------------------------------- /docs/src/content/docs/ko/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hollo란? 3 | description: Hollo는 1인 사용자용 연합 마이크로블로그 소프트웨어입니다. 4 | --- 5 | 6 | Hollo는 여러분만의 작은 인터넷 공간을 만들 수 있는 간편한 1인 사용자용 마이크로블로그입니다. 7 | 개인용 Mastodon 인스턴스라고 생각해도 되지만, 정말 필요한 기능들로만 구성되어 있습니다. 8 | 9 | Hollo의 특징은 [연합우주][](fediverse)의 일부라는 것입니다. 10 | 연합우주란 [ActivityPub] 프로토콜을 통해 서로 연결된 서버들의 네트워크를 뜻합니다. 11 | 즉, Hollo를 사용하면 다음과 같은 다른 연합우주 플랫폼의 사용자들과 연결될 수 있습니다: 12 | 13 | - [Mastodon] 14 | - [Misskey] 15 | - [Akkoma] 16 | - [WordPress] 17 | - [Threads] 18 | - 그 밖의 다른 연합우주 서버들 19 | 20 | 따라서, 여러분만의 조용한 공간을 갖는 동시에, 넓은 커뮤니티와 연결되어 있을 수 있습니다. 21 | 22 | [연합우주]: https://ko.wikipedia.org/wiki/%EC%97%B0%ED%95%A9_%EC%9A%B0%EC%A3%BC 23 | [ActivityPub]: https://activitypub.rocks/ 24 | [Mastodon]: https://joinmastodon.org/ko 25 | [Misskey]: https://misskey-hub.net/ko/ 26 | [Akkoma]: https://akkoma.social/ 27 | [WordPress]: https://ko.wordpress.org/ 28 | [Threads]: https://www.threads.net/ 29 | 30 | 31 | 기능 32 | ---- 33 | 34 | Hollo는 대부분의 개인 사용자가 필요한 기능에 대해서는 Mastodon과 동등한 기능 집합을 제공합니다. 35 | 기대할 수 있는 주요 마이크로블로그 기능을 모두 제공하는 한편, 36 | (모더레이션 도구와 같이) 1인 사용자 인스턴스에는 필요 없는 기능은 제외되었습니다. 37 | 즉, 다음과 같은 것들을 할 수 있습니다: 38 | 39 | - 해시태그와 멘션 사용 40 | - 미디어 첨부 41 | - 투표 생성 42 | - 글 공유 및 즐겨찾기 43 | - Misskey 스타일의 에모지 리액션 44 | 45 | 한편, 게시물 작성에 대해서는 Hollo는 아주 풍부한 기능을 제공합니다: 46 | 47 | - 최대 10,000자의 글 작성 48 | - (흔히 Markdown이라 불리는) [CommonMark] 문법 지원 49 | - Misskey 스타일의 인용 50 | 51 | Hollo는 자체적인 웹 인터페이스가 없는 「헤들리스」(headless) 소프트웨어입니다. 52 | 대신, 여러분은 아무 Mastodon 호환 클라이언트 앱으로 Hollo를 사용할 수 있습니다. 53 | 처음에는 이상하게 들릴 수 있지만, 54 | 이는 여러분에게 가장 익숙하고 잘 맞는 앱으로 Hollo를 쓸 수 있게 해 줍니다. 55 | 56 | 요는, Hollo는 Mastodon이나 Misskey의 기능을 개인 사용자가 그대로 누릴 수 있는 간편한 도구입니다. 57 | 짧은 근황 공유부터 긴 사색에 이르기까지 온라인에 여러분의 생각을 공유하고, 58 | 연합우주의 다른 사람들과 연결되고, 59 | 여러분만의 방식으로 이 모든 것을 할 수 있도록 돕습니다. 60 | 차세대 대형 소셜 네트워크가 아닌, 마이크로블로깅을 좀 더 쉽고 아늑하게 즐길 수 있도록 만들어졌습니다. 61 | 62 | [CommonMark]: https://commonmark.org/ 63 | 64 | 65 | 라이선스 66 | -------- 67 | 68 | Hollo는 [GNU 아페로 일반 공중 사용 허가서 버전 3][AGPLv3] 및 그 이후 버전으로 배포됩니다. 69 | 즉, 소스 코드를 공개하고 변경 사항을 다른 사람들과 공유하는 한 자유롭게 사용, 수정 및 배포할 수 있습니다. 70 | 71 | [AGPLv3]: https://www.gnu.org/licenses/agpl-3.0 72 | 73 | 74 | 어원 75 | ---- 76 | 77 | Hollo는 한국어 낱말인 「홀로」에서 이름을 따왔습니다. 78 | 1인 사용자용 소프트웨어이기에 그러한 이름을 갖게 되었습니다. 79 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/clients.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 已测试客户端 3 | description: >- 4 | Hollo 是一款无头微博软件,它实现了 Mastodon API。 5 | 这意味着您可以使用任何与 Mastodon 兼容的客户端与其进行交互。 6 | 以下是一些经过 Hollo 测试的客户端。 7 | --- 8 | 9 | import { Badge } from '@astrojs/starlight/components'; 10 | 11 | Hollo 是一款无头微博软件,它实现了 Mastodon API。 12 | 这意味着您可以使用任何与 Mastodon 兼容的客户端与其进行交互。 13 | 然而,实际上,由于 Mastodon API 的实现方式不同, 14 | 某些客户端可能无法按预期工作。 15 | 以下是一些经过 Hollo 测试的客户端。 16 | 17 | 18 | Web 19 | --- 20 | 21 | ### [Elk](https://elk.zone/) 22 | 23 | - 引用是只写的。[^1] 24 | - 不支持表情反应。 25 | 26 | [^1]: 您可以在撰写新帖子时通过粘贴链接来引用帖子。 27 | 28 | ### [Phanpy](https://phanpy.social/) 29 | 30 | - 表情反应是只读的。 31 | 32 | 33 | Android 34 | ------- 35 | 36 | ### [Moshidon](https://lucasggamerm.github.io/moshidon/) 37 | 38 | - 您需要打开 *设置* → *实例* → *启用表情回应*,才能使用表情符号反应。 39 | - 您需要打开 *设置* → *实例* → *启用嘟文格式*, 40 | 并将 *默认的内容类型* 设置为 *Markdown*,才能使用 CommonMark。 41 | 42 | ### [Subway Tooter](https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter) 43 | 44 | - 引用是只写的。[^1] 45 | 46 | 47 | iOS 48 | --- 49 | 50 | ### [Mona](https://getmona.app/) 51 | 52 | - 不支持表情反应。 53 | 54 | ### [Nightfox DAWN](https://noppe.dev/dawn) 55 | 56 | - 要引用,您需要手动粘贴帖子的链接。[^1] 57 | - 不支持中文。 58 | 59 | ### [Tusker](https://vaccor.space/tusker/) 60 | 61 | - 引用是只写的。[^1] 62 | - 不支持表情反应。 63 | - 不支持中文。 64 | 65 | ### [Woolly](https://apps.apple.com/app/woolly-for-mastodon/id6444360628) 66 | 67 | - 引用是只写的。[^1] 68 | - 不支持表情反应。 69 | - 不支持中文。 70 | 71 | ---- 72 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 欢迎使用 Hollo 3 | description: >- 4 | Hollo 是一款面向单用户的联邦式微博软件。 5 | template: splash 6 | hero: 7 | tagline: >- 8 | 一款面向单用户的联邦式微博软件。 9 | 支持 ActivityPub,兼容 Mastodon API,支持 CommonMark 和 Misskey 风格的引用。 10 | image: 11 | light: ../../../assets/logo-black.svg 12 | dark: ../../../assets/logo-white.svg 13 | actions: 14 | - text: 什么是 Hollo? 15 | link: /zh-cn/intro 16 | icon: right-arrow 17 | variant: secondary 18 | - text: 安装 19 | link: /zh-cn/install/railway 20 | icon: rocket 21 | variant: primary 22 | - text: 官方实例 23 | link: https://hollo.social/@hollo 24 | icon: external 25 | variant: minimal 26 | --- 27 | 28 | import { Card, CardGrid } from "@astrojs/starlight/components"; 29 | 30 | 31 | 32 | Hollo 专为单用户设计,让您可以拥有自己的实例并完全控制您的数据。 33 | 非常适合个人微博、笔记和日记。 34 | 35 | 36 | 虽然 Hollo 是为单用户设计的,但您可以在同一实例上拥有多个账户。 37 | 您可以轻松切换账户,每个账户都有自己的资料页、帖文和设置。 38 | 39 | 40 | Hollo 是无头的;它遵循 [Mastodon API],因此您可以使用任何兼容 Mastodon 的客户端与其交互。 41 | 42 | [Mastodon API]: https://docs.joinmastodon.org/methods/ 43 | 44 | 45 | 您可以使用 [CommonMark](即 Markdown)撰写贴文,Hollo 会为您呈现——当然,其他联邦宇宙软件也会呈现。 46 | 此外,每个贴文允许最多 10,000 个字符。 47 | 48 | [CommonMark]: https://commonmark.org/ 49 | 50 | 51 | Hollo 支持 [Misskey] 风格的引用,您可以在自己的贴文中引用其他贴文。 52 | 它与支持 Misskey 风格引用的其他联邦宇宙软件兼容,例如 Misskey、[Akkoma]、[Fedibird]、[Threads]。 53 | 54 | [Misskey]: https://misskey-hub.net/cn/ 55 | [Akkoma]: https://akkoma.social/ 56 | [Fedibird]: https://github.com/fedibird/mastodon 57 | [Threads]: https://www.threads.net/ 58 | 59 | 60 | Hollo 支持 [Misskey] 风格的表情回应, 61 | 您可以使用 Unicode 表情符号和自定义表情符号对贴文进行回应。 62 | 它兼容其他支持 Misskey 风格表情回应的联邦宇宙软件, 63 | 例如 Misskey 和 [Akkoma]。 64 | 65 | [Misskey]: https://misskey-hub.net/cn/ 66 | [Akkoma]: https://akkoma.social/ 67 | 68 | 69 | Hollo 由 [Fedify] 提供支持,这是一个用于 TypeScript 的 ActivityPub 服务端框架。 70 | 71 | [Fedify]: https://fedify.dev/ 72 | 73 | 74 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/install/docker.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 使用 Docker 部署 3 | description: 如何使用 Docker 部署 Hollo。 4 | --- 5 | 6 | Hollo 在 [GitHub Packages] 上提供了官方的 Docker 镜像。您可以使用这些镜像在您的服务器或本地机器上部署 Hollo: 7 | 8 | ~~~~ sh frame="none" 9 | docker pull ghcr.io/fedify-dev/hollo:latest 10 | ~~~~ 11 | 12 | 要运行 Hollo,您需要设置一个 PostgreSQL 数据库和一个用于媒体存储的 S3 兼容对象存储。您可以使用 [PostgreSQL] 的官方 Docker 镜像,也可以使用 [MinIO] 作为 S3 兼容对象存储。或者,您也可以使用其他托管服务,如 AWS 的 [RDS]、[ElastiCache] 和 [S3]。 13 | 14 | 要将 Hollo 连接到这些服务,您需要通过 `docker run` 命令的 [`-e`/`--env` 选项或 `--env-file` 选项][1] 设置环境变量。要查看 Hollo 支持的环境变量,请参阅 [**环境变量**](/zh-cn/install/env) 章节。 15 | 16 | [GitHub Packages]: https://github.com/fedify-dev/hollo/pkgs/container/hollo 17 | [PostgreSQL]: https://hub.docker.com/_/postgres 18 | [MinIO]: https://hub.docker.com/r/minio/minio 19 | [RDS]: https://aws.amazon.com/rds/ 20 | [ElastiCache]: https://aws.amazon.com/elasticache/ 21 | [S3]: https://aws.amazon.com/s3/ 22 | [1]: https://docs.docker.com/reference/cli/docker/container/run/#env 23 | 24 | 25 | Docker Compose 26 | -------------- 27 | 28 | import { Code } from "@astrojs/starlight/components"; 29 | import composeYaml from "../../install/docker/compose-yaml?raw"; 30 | 31 | 要连接这些服务,您可以使用 [Docker Compose]。以下是一个示例 *compose.yaml* 文件: 32 | 33 | 34 | 35 | 将此文件保存为 *compose.yaml* 到您的工作目录中,然后运行以下命令: 36 | 37 | ~~~~ sh frame="none" 38 | docker compose up -d 39 | ~~~~ 40 | 41 | [Docker Compose]: https://docs.docker.com/compose/ 42 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/install/manual.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 手动安装 3 | description: 如何手动安装 Hollo。 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | Hollo 可以手动安装在您的服务器上。本指南将引导您完成在服务器上设置 Hollo 的过程。假设您有运行 Web 应用程序的经验,并且熟悉命令行操作。 9 | 10 | 15 | 16 | 17 | 准备工作 18 | -------- 19 | 20 | 开始之前,请确保您的服务器上安装了以下软件: 21 | 22 | - [Git] 23 | - [Node.js] 24+ 24 | - [pnpm] 25 | - [ffmpeg] 26 | - [PostgreSQL] 17+ 27 | - L7 负载均衡器(例如,[nginx],[Caddy]) 28 | - 指向您服务器的域名 29 | 30 | [Git]: https://git-scm.com/ 31 | [Node.js]: https://nodejs.org/ 32 | [pnpm]: https://pnpm.io/ 33 | [ffmpeg]: https://www.ffmpeg.org/ 34 | [PostgreSQL]: https://www.postgresql.org/ 35 | [nginx]: https://nginx.org/ 36 | [Caddy]: https://caddyserver.com/ 37 | 38 | 39 | 安装 40 | ---- 41 | 42 | import { Steps } from "@astrojs/starlight/components"; 43 | 44 | 45 | 1. 从 [GitHub] 获取 Hollo 的最新代码: 46 | 47 | ~~~~ sh frame="none" 48 | git clone -b stable https://github.com/fedify-dev/hollo.git 49 | cd hollo/ 50 | ~~~~ 51 | 52 | 2. 使用 pnpm 安装依赖项: 53 | 54 | ~~~~ sh frame="none" 55 | pnpm install 56 | ~~~~ 57 | 58 | 3. 为 Hollo 创建 PostgreSQL 用户和数据库: 59 | 60 | ~~~~ sh frame="none" 61 | createuser --createdb --pwprompt hollo 62 | createdb --username=hollo --encoding=utf8 --template=postgres hollo 63 | ~~~~ 64 | 65 | 4. 为 Hollo 创建配置文件: 66 | 67 | ~~~~ sh frame="none" 68 | cp .env.sample .env 69 | ~~~~ 70 | 71 | [GitHub]: https://github.com/fedify-dev/hollo 72 | 73 | 74 | 75 | 配置 76 | ---- 77 | 78 | 安装 Hollo 后,您需要进行配置。打开之前创建的 *.env* 文件并调整环境变量。 79 | 80 | 有关如何配置 Hollo 的详细信息,请参阅 [**环境变量**](/zh-cn/install/env) 章节。 81 | 82 | 83 | 启动服务端 84 | ---------- 85 | 86 | 要启动服务端,请运行以下命令: 87 | 88 | ~~~~ sh frame="none" 89 | pnpm run prod 90 | ~~~~ 91 | 92 | 93 | 升级 Hollo 94 | ---------- 95 | 96 | 要升级 Hollo,只需从 GitHub 拉取最新代码并重新安装依赖项: 97 | 98 | 99 | 1. 从 GitHub 拉取最新代码: 100 | 101 | ~~~~ sh frame="none" 102 | git pull 103 | ~~~~ 104 | 105 | 2. 重新安装依赖项: 106 | 107 | ~~~~ sh frame="none" 108 | pnpm install 109 | ~~~~ 110 | 111 | 3. 重启服务端: 112 | 113 | ~~~~ sh frame="none" 114 | pnpm run prod 115 | ~~~~ 116 | 117 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/install/railway.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 部署到 Railway 3 | description: 如何在 Railway 上部署 Hollo。 4 | --- 5 | 6 | import { Aside } from "@astrojs/starlight/components"; 7 | 8 | [![在 Railway 上部署][]][Railway 模板] 9 | 10 | 部署 Hollo 最简单的方法是使用 [Railway]。Railway 是一个可以轻松部署应用的平台,支持多种语言和框架,包括 Node.js、Python、Ruby 等。 11 | 12 | 点击上方按钮即可在 Railway 上部署 Hollo。通过这个模板,您只需几次点击即可开始使用自己的 Hollo。 13 | 14 | 要部署 Hollo,您需要 S3 或兼容 S3 的对象存储来存储媒体文件,如图片。目前有许多兼容 S3 的对象存储服务,包括 AWS S3、Cloudflare R2、MinIO、DigitalOcean Spaces 和 Linode Object Storage。准备好对象存储后,您需要适当地配置环境变量(请参阅各服务的 S3 客户端 API 使用方法)。更多信息请参见[**环境变量**](/zh-cn/install/env)章节。 15 | 16 | 设置好环境变量并在 Railway 上部署了 Hollo之后,请访问 https://yourdomain/setup 来设置您的登录凭据并添加您的个人资料。 17 | 18 | 21 | 22 | 创建个人资料后,您就可以开始享受 Hollo 了。值得注意的是,Hollo 本身没有太多的网页界面,因此您需要使用像 [Phanpy] 这样的客户端应用。 23 | 24 | [Railway]: https://railway.app/ 25 | [在 Railway 上部署]: https://railway.app/button.svg 26 | [Railway 模板]: https://railway.app/template/eopPyH?referralCode=qeEK5G 27 | [Phanpy]: https://phanpy.social/ 28 | 29 | 30 | 升级 31 | --------- 32 | 33 | import { Steps } from "@astrojs/starlight/components"; 34 | 35 | 要升级 Hollo,只需在 Railway 上重新部署服务: 36 | 37 | 38 | 1. 前往 Railway 仪表板。 39 | 40 | 2. 选择您的 Hollo 项目。 41 | 42 | 3. 选择您的 Hollo 服务。 43 | 44 | ![选择您的 Hollo 服务](../../install/railway/project.png) 45 | 46 | 4. 在部署中,点击右上角看起来像三个竖点的按钮。 47 | 48 | 5. 在下拉菜单中,点击 **Redeploy** 以重新部署服务。 49 | 50 | ![重新部署服务](../../install/railway/deployments.png) 51 | 52 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/install/setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 配置指南 3 | description: 如何配置 Hollo。 4 | --- 5 | 6 | import { Aside, Steps } from "@astrojs/starlight/components"; 7 | 8 | 12 | 13 | 安装 Hollo 后,您需要进行配置。本指南将引导您在服务器上配置 Hollo 。 14 | 15 | 16 | 1. 访问 **https://yourdomain/setup**,其中 yourdomain 是您的域名。 17 | 18 | 2. 设置您的登录凭据。 19 | 20 | ![配置登录凭据](../../install/setup/setup.png) 21 | 22 | 3. 您将看到空的 **Accounts**(账户)页面。点击 **Crate a new account**(创建新账户)按钮。 23 | 24 | ![空账户页面](../../install/setup/accounts-empty.png) 25 | 26 | 4. 填写表单以创建您的账户。除 **Username**(用户名)字段外,其他字段可以在之后更改。 27 | 28 | ![创建新账户](../../install/setup/create-account.png) 29 | 30 | 5. 创建账户后,您将看到 **Accounts**(账户)页面列出了您的账户。 31 | 32 | ![账户页面](../../install/setup/accounts.png) 33 | 34 | 6. 由于 Hollo 是无界面的,您需要使用兼容 Mastodon 的客户端应用,例如 [Phanpy],来与其交互。这里以 Phanpy 为例。 35 | 36 | 访问 **https://phanpy.social/** 并点击 **使用 Mastodon 登录** 按钮开始登录。 37 | 38 | 7. 输入您的 Hollo 域名并点击 **继续**。 39 | 40 | ![Phanpy](../../install/setup/phanpy-login.png) 41 | 42 | 8. 此时,您可能需要登录到您的 Hollo 账户。输入您的用户名和密码并点击 **Sign in**(登录)。 43 | 44 | 如果不需要登录,请跳到下一步。 45 | 46 | 9. 您将看到 **Authorize Phanpy**(授权 Phanpy)页面。点击 **Allow**(允许)以授权 Phanpy 访问您的 Hollo 账户。 47 | 48 | ![授权 Phanpy](../../install/setup/authorize.png) 49 | 50 | 10. 这样就完成了!您现在已使用 Phanpy 登录到您的 Hollo 账户。 51 | 52 | 时间线一开始会是空的,但您可以开始发布内容和关注其他用户。如果您想关注官方 Hollo 账户,请搜索 `@hollo@hollo.social` 并在个人资料页面上点击 *关注* 按钮。 53 | 54 | 享受吧! 55 | 56 | ![Phanpy](../../install/setup/phanpy.png) 57 | 58 | [Phanpy]: https://phanpy.social/ 59 | 60 | -------------------------------------------------------------------------------- /docs/src/content/docs/zh-cn/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 什么是 Hollo? 3 | description: Hollo 是一款面向单用户场景的联邦式微博软件。 4 | --- 5 | 6 | Hollo 是一个简单的单用户微博工具,让你可以在网络上拥有自己的小天地。你可以将它视为你的个人 Mastodon 实例,但仅保留了核心功能。 7 | 8 | Hollo 的独特之处在于它是 [联邦宇宙](https://www.theverge.com/24063290/fediverse-explained-activitypub-social-media-open-protocol) 的一部分——一个运行在开放协议上的互联服务器网络,主要使用 [ActivityPub](https://activitypub.rocks/)。这意味着你可以与其他平台上的用户连接和互动,比如: 9 | 10 | - [Mastodon](https://joinmastodon.org/zh) 11 | - [Misskey](https://misskey-hub.net/cn/) 12 | - [Akkoma](https://akkoma.social/) 13 | - [WordPress](https://cn.wordpress.org/) 14 | - [Threads](https://www.threads.net/) 15 | - 以及许多其他联邦宇宙服务器 16 | 17 | 这样,你既能享受个人空间的宁静,又能连接到更广泛的社区。 18 | 19 | 功能 20 | ---- 21 | 22 | Hollo 在大多数单用户需求上与 Mastodon 功能相当。你可以获得所有核心微博功能,但不会有不适合单用户实例的部分(如管理工具)。这意味着你可以: 23 | 24 | - 使用标签和提及 25 | - 分享媒体附件 26 | - 创建投票 27 | - 分享和收藏贴文 28 | - Misskey 风格的表情回应 29 | 30 | 在撰写贴文时,Hollo 保持灵活性。你可以: 31 | 32 | - 撰写最多 10,000 字的贴文 33 | - 使用 [CommonMark](https://commonmark.org/)(即 Markdown)进行轻松格式化 34 | - 使用 Misskey 风格的引用为你的贴文增添风采 35 | 36 | Hollo 是所谓的“无头”软件,意思是它没有自己的网页界面。相反,你可以使用任何与 Mastodon 兼容的应用程序进行发布和互动。乍一听可能有些奇怪,但实际上这让你可以选择最适合你风格的应用。 37 | 38 | 本质上,Hollo 是一个简单的工具,将 Mastodon 或 Misskey 的力量带给个人用户。它帮助你在线分享想法——从快速更新到更长的思考——在联邦宇宙中与他人连接,并以自己的方式进行。它并不想成为下一个大型社交网络——只是希望让微博变得更简单、更连通。 39 | 40 | 许可 41 | ---- 42 | 43 | Hollo 根据 [GNU Affero 通用公共许可证第 3 版](https://www.gnu.org/licenses/agpl-3.0.zh-cn.html)或更新版本的条款发布,这意味着你可以自由使用、修改和分发它,前提是你保持源代码开放并与他人共享你的更改。 44 | 45 | 词源 46 | ---- 47 | 48 | 名称 *Hollo* 源自韩语词 *홀로*,意为“独自”或“孤身”。之所以这样命名,是因为它被设计为单用户软件。 49 | -------------------------------------------------------------------------------- /docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /docs/src/styles/custom.css: -------------------------------------------------------------------------------- 1 | /* Dark mode colors. */ 2 | :root { 3 | --sl-color-accent-low: #00273d; 4 | --sl-color-accent: #0071a7; 5 | --sl-color-accent-high: #92d1fe; 6 | --sl-color-white: #ffffff; 7 | --sl-color-gray-1: #e7eff2; 8 | --sl-color-gray-2: #bac4c8; 9 | --sl-color-gray-3: #7b8f96; 10 | --sl-color-gray-4: #495c62; 11 | --sl-color-gray-5: #2a3b41; 12 | --sl-color-gray-6: #182a2f; 13 | --sl-color-black: #121a1c; 14 | } 15 | /* Light mode colors. */ 16 | :root[data-theme="light"] { 17 | --sl-color-accent-low: #b0deff; 18 | --sl-color-accent: #0073aa; 19 | --sl-color-accent-high: #003653; 20 | --sl-color-white: #121a1c; 21 | --sl-color-gray-1: #182a2f; 22 | --sl-color-gray-2: #2a3b41; 23 | --sl-color-gray-3: #495c62; 24 | --sl-color-gray-4: #7b8f96; 25 | --sl-color-gray-5: #bac4c8; 26 | --sl-color-gray-6: #e7eff2; 27 | --sl-color-gray-7: #f3f7f9; 28 | --sl-color-black: #ffffff; 29 | } 30 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | // biome-ignore lint/complexity/useLiteralKeys: tsc rants about this (TS4111) 4 | const databaseUrl = process.env["DATABASE_URL"]; 5 | if (databaseUrl == null) throw new Error("DATABASE_URL must be defined"); 6 | 7 | export default { 8 | schema: "./src/schema.ts", 9 | out: "./drizzle", 10 | dialect: "postgresql", 11 | dbCredentials: { 12 | url: databaseUrl, 13 | }, 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /drizzle/0000_init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "credentials" ( 2 | "email" varchar(254) PRIMARY KEY NOT NULL, 3 | "password_hash" text NOT NULL, 4 | "created" timestamp with time zone DEFAULT now() NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /drizzle/0001_accounts.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."account_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "account_owners" ( 8 | "id" uuid PRIMARY KEY NOT NULL, 9 | "private_key_jwk" jsonb NOT NULL, 10 | "public_key_jwk" jsonb NOT NULL, 11 | "bio" text 12 | ); 13 | --> statement-breakpoint 14 | CREATE TABLE IF NOT EXISTS "accounts" ( 15 | "id" uuid PRIMARY KEY NOT NULL, 16 | "iri" text NOT NULL, 17 | "type" "account_type" NOT NULL, 18 | "name" varchar(100) NOT NULL, 19 | "handle" text NOT NULL, 20 | "bio_html" text, 21 | "url" text, 22 | "protected" boolean DEFAULT false NOT NULL, 23 | "avatar_url" text, 24 | "cover_url" text, 25 | "inbox_url" text NOT NULL, 26 | "followers_url" text, 27 | "shared_inbox_url" text, 28 | "following" bigint DEFAULT 0, 29 | "followers" bigint DEFAULT 0, 30 | "posts" bigint DEFAULT 0, 31 | "published" timestamp with time zone, 32 | "fetched" timestamp with time zone DEFAULT now() NOT NULL, 33 | CONSTRAINT "accounts_iri_unique" UNIQUE("iri"), 34 | CONSTRAINT "accounts_handle_unique" UNIQUE("handle") 35 | ); 36 | --> statement-breakpoint 37 | DO $$ BEGIN 38 | ALTER TABLE "account_owners" ADD CONSTRAINT "account_owners_id_accounts_id_fk" FOREIGN KEY ("id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; 39 | EXCEPTION 40 | WHEN duplicate_object THEN null; 41 | END $$; 42 | -------------------------------------------------------------------------------- /drizzle/0002_Rename.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" RENAME COLUMN "fetched" TO "updated"; -------------------------------------------------------------------------------- /drizzle/0003_applications.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."scope" AS ENUM('read', 'read:accounts', 'read:blocks', 'read:bookmarks', 'read:favourites', 'read:filters', 'read:follows', 'read:lists', 'read:mutes', 'read:notifications', 'read:search', 'read:statuses', 'write', 'write:accounts', 'write:blocks', 'write:bookmarks', 'write:conversations', 'write:favourites', 'write:filters', 'write:follows', 'write:lists', 'write:media', 'write:mutes', 'write:notifications', 'write:reports', 'write:statuses', 'follow', 'push'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "applications" ( 8 | "id" uuid PRIMARY KEY NOT NULL, 9 | "name" varchar(256) NOT NULL, 10 | "redirect_uri" text NOT NULL, 11 | "scopes" scope[] NOT NULL, 12 | "website" text, 13 | "client_id" text NOT NULL, 14 | "client_secret" text NOT NULL, 15 | CONSTRAINT "applications_client_id_unique" UNIQUE("client_id") 16 | ); 17 | -------------------------------------------------------------------------------- /drizzle/0004_make.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "applications" RENAME COLUMN "redirect_uri" TO "redirect_uris";--> statement-breakpoint 2 | ALTER TABLE "applications" ALTER COLUMN "redirect_uris" SET DATA TYPE text[] 3 | USING ARRAY[redirect_uris]; 4 | -------------------------------------------------------------------------------- /drizzle/0005_access.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "access_tokens" ( 2 | "code" text PRIMARY KEY NOT NULL, 3 | "application_id" uuid NOT NULL, 4 | "scopes" scope[] NOT NULL, 5 | "created" timestamp with time zone DEFAULT now() NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | ALTER TABLE "applications" ADD COLUMN "created" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE no action ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | -------------------------------------------------------------------------------- /drizzle/0006_access_tokens.accout_owner_id.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."grant_type" AS ENUM('authorization_code', 'client_credentials'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | ALTER TABLE "access_tokens" ADD COLUMN "account_owner_id" uuid;--> statement-breakpoint 8 | ALTER TABLE "access_tokens" ADD COLUMN "grant_type" "grant_type" DEFAULT 'authorization_code' NOT NULL;--> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE no action ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | -------------------------------------------------------------------------------- /drizzle/0007_posts.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."post_type" AS ENUM('Article', 'Note'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | DO $$ BEGIN 8 | CREATE TYPE "public"."post_visibility" AS ENUM('public', 'unlisted', 'private', 'direct'); 9 | EXCEPTION 10 | WHEN duplicate_object THEN null; 11 | END $$; 12 | --> statement-breakpoint 13 | CREATE TABLE IF NOT EXISTS "mentions" ( 14 | "post_id" uuid NOT NULL, 15 | "account_id" uuid NOT NULL, 16 | CONSTRAINT "mentions_post_id_account_id_pk" PRIMARY KEY("post_id","account_id") 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE IF NOT EXISTS "posts" ( 20 | "id" uuid PRIMARY KEY NOT NULL, 21 | "iri" text NOT NULL, 22 | "type" "post_type" NOT NULL, 23 | "actor_id" uuid NOT NULL, 24 | "application_id" uuid, 25 | "reply_target_id" uuid, 26 | "sharing_id" uuid, 27 | "visibility" "post_visibility" NOT NULL, 28 | "summary_html" text, 29 | "content_html" text, 30 | "language" text, 31 | "tags" jsonb DEFAULT '{}'::jsonb NOT NULL, 32 | "sensitive" boolean DEFAULT false NOT NULL, 33 | "url" text, 34 | "replies_count" bigint DEFAULT 0, 35 | "shares_count" bigint DEFAULT 0, 36 | "likes_count" bigint DEFAULT 0, 37 | "published" timestamp with time zone, 38 | "updated" timestamp with time zone DEFAULT now() NOT NULL, 39 | CONSTRAINT "posts_iri_unique" UNIQUE("iri") 40 | ); 41 | --> statement-breakpoint 42 | DO $$ BEGIN 43 | ALTER TABLE "mentions" ADD CONSTRAINT "mentions_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action; 44 | EXCEPTION 45 | WHEN duplicate_object THEN null; 46 | END $$; 47 | --> statement-breakpoint 48 | DO $$ BEGIN 49 | ALTER TABLE "mentions" ADD CONSTRAINT "mentions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; 50 | EXCEPTION 51 | WHEN duplicate_object THEN null; 52 | END $$; 53 | --> statement-breakpoint 54 | DO $$ BEGIN 55 | ALTER TABLE "posts" ADD CONSTRAINT "posts_actor_id_accounts_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; 56 | EXCEPTION 57 | WHEN duplicate_object THEN null; 58 | END $$; 59 | --> statement-breakpoint 60 | DO $$ BEGIN 61 | ALTER TABLE "posts" ADD CONSTRAINT "posts_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE set null ON UPDATE no action; 62 | EXCEPTION 63 | WHEN duplicate_object THEN null; 64 | END $$; 65 | --> statement-breakpoint 66 | DO $$ BEGIN 67 | ALTER TABLE "posts" ADD CONSTRAINT "posts_reply_target_id_posts_id_fk" FOREIGN KEY ("reply_target_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action; 68 | EXCEPTION 69 | WHEN duplicate_object THEN null; 70 | END $$; 71 | --> statement-breakpoint 72 | DO $$ BEGIN 73 | ALTER TABLE "posts" ADD CONSTRAINT "posts_sharing_id_posts_id_fk" FOREIGN KEY ("sharing_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 74 | EXCEPTION 75 | WHEN duplicate_object THEN null; 76 | END $$; 77 | -------------------------------------------------------------------------------- /drizzle/0008_fields.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" ADD COLUMN "fields" json DEFAULT '{}'::json NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "accounts" ADD COLUMN "field_htmls" json DEFAULT '{}'::json NOT NULL; 3 | -------------------------------------------------------------------------------- /drizzle/0009_follows.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "follows" ( 2 | "following_id" uuid NOT NULL, 3 | "follower_id" uuid NOT NULL, 4 | "shares" boolean DEFAULT true NOT NULL, 5 | "notify" boolean DEFAULT false NOT NULL, 6 | "languages" text[], 7 | "created" timestamp with time zone DEFAULT now() NOT NULL, 8 | CONSTRAINT "follows_following_id_follower_id_pk" PRIMARY KEY("following_id","follower_id") 9 | ); 10 | --> statement-breakpoint 11 | ALTER TABLE "accounts" RENAME COLUMN "following" TO "following_count";--> statement-breakpoint 12 | ALTER TABLE "accounts" RENAME COLUMN "followers" TO "followers_count";--> statement-breakpoint 13 | ALTER TABLE "accounts" RENAME COLUMN "posts" TO "posts_count";--> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_accounts_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."accounts"("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 "follows" ADD CONSTRAINT "follows_follower_id_accounts_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | -------------------------------------------------------------------------------- /drizzle/0010_follows.approved.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "follows" ADD COLUMN "approved" timestamp with time zone; -------------------------------------------------------------------------------- /drizzle/0011_follows.iri.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "follows" ADD COLUMN "iri" text; 2 | UPDATE "follows" SET "iri" = 'urn:uuid:' || gen_random_uuid()::text; 3 | ALTER TABLE "follows" ALTER COLUMN "iri" SET NOT NULL; 4 | -------------------------------------------------------------------------------- /drizzle/0012_account_owners.handle.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" ADD COLUMN "handle" text; 2 | UPDATE "account_owners" 3 | SET "handle" = regexp_replace("accounts"."handle", '^@|@[^@]+$', '', 'g') 4 | FROM "accounts" WHERE "account_owners"."id" = "accounts"."id"; 5 | -------------------------------------------------------------------------------- /drizzle/0013_likes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "likes" ( 2 | "post_id" uuid NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "created" timestamp with time zone DEFAULT now() NOT NULL, 5 | CONSTRAINT "likes_post_id_account_id_pk" PRIMARY KEY("post_id","account_id") 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | --> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "likes" ADD CONSTRAINT "likes_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | -------------------------------------------------------------------------------- /drizzle/0014_bookmarks.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "bookmarks" ( 2 | "post_id" uuid NOT NULL, 3 | "account_owner_id" uuid NOT NULL, 4 | "created" timestamp with time zone DEFAULT now() NOT NULL, 5 | CONSTRAINT "bookmarks_post_id_account_owner_id_pk" PRIMARY KEY("post_id","account_owner_id") 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | --> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | -------------------------------------------------------------------------------- /drizzle/0015_delete-cascade.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "access_tokens" DROP CONSTRAINT "access_tokens_account_owner_id_account_owners_id_fk"; 2 | --> statement-breakpoint 3 | ALTER TABLE "account_owners" DROP CONSTRAINT "account_owners_id_accounts_id_fk"; 4 | --> statement-breakpoint 5 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_post_id_posts_id_fk"; 6 | --> statement-breakpoint 7 | ALTER TABLE "follows" DROP CONSTRAINT "follows_following_id_accounts_id_fk"; 8 | --> statement-breakpoint 9 | ALTER TABLE "likes" DROP CONSTRAINT "likes_post_id_posts_id_fk"; 10 | --> statement-breakpoint 11 | ALTER TABLE "mentions" DROP CONSTRAINT "mentions_post_id_posts_id_fk"; 12 | --> statement-breakpoint 13 | ALTER TABLE "posts" DROP CONSTRAINT "posts_actor_id_accounts_id_fk"; 14 | --> statement-breakpoint 15 | DO $$ BEGIN 16 | ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 17 | EXCEPTION 18 | WHEN duplicate_object THEN null; 19 | END $$; 20 | --> statement-breakpoint 21 | DO $$ BEGIN 22 | ALTER TABLE "account_owners" ADD CONSTRAINT "account_owners_id_accounts_id_fk" FOREIGN KEY ("id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 23 | EXCEPTION 24 | WHEN duplicate_object THEN null; 25 | END $$; 26 | --> statement-breakpoint 27 | DO $$ BEGIN 28 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 29 | EXCEPTION 30 | WHEN duplicate_object THEN null; 31 | END $$; 32 | --> statement-breakpoint 33 | DO $$ BEGIN 34 | ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_accounts_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 35 | EXCEPTION 36 | WHEN duplicate_object THEN null; 37 | END $$; 38 | --> statement-breakpoint 39 | DO $$ BEGIN 40 | ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 41 | EXCEPTION 42 | WHEN duplicate_object THEN null; 43 | END $$; 44 | --> statement-breakpoint 45 | DO $$ BEGIN 46 | ALTER TABLE "mentions" ADD CONSTRAINT "mentions_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 47 | EXCEPTION 48 | WHEN duplicate_object THEN null; 49 | END $$; 50 | --> statement-breakpoint 51 | DO $$ BEGIN 52 | ALTER TABLE "posts" ADD CONSTRAINT "posts_actor_id_accounts_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 53 | EXCEPTION 54 | WHEN duplicate_object THEN null; 55 | END $$; 56 | -------------------------------------------------------------------------------- /drizzle/0016_delete-cascade.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "access_tokens" DROP CONSTRAINT "access_tokens_application_id_applications_id_fk"; 2 | --> statement-breakpoint 3 | ALTER TABLE "follows" DROP CONSTRAINT "follows_follower_id_accounts_id_fk"; 4 | --> statement-breakpoint 5 | DO $$ BEGIN 6 | ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE cascade ON UPDATE no action; 7 | EXCEPTION 8 | WHEN duplicate_object THEN null; 9 | END $$; 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_accounts_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | -------------------------------------------------------------------------------- /drizzle/0017_followed_tags.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" ADD COLUMN "followed_tags" text[] DEFAULT '{}' NOT NULL; 2 | -------------------------------------------------------------------------------- /drizzle/0018_preview_card.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "posts" ADD COLUMN "preview_card" jsonb; -------------------------------------------------------------------------------- /drizzle/0019_more.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" ADD COLUMN "visibility" "post_visibility" DEFAULT 'public' NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "account_owners" ADD COLUMN "language" text DEFAULT 'en' NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "accounts" ADD COLUMN "sensitive" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0020_status_source.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "posts" ADD COLUMN "summary" text;--> statement-breakpoint 2 | ALTER TABLE "posts" ADD COLUMN "content" text; -------------------------------------------------------------------------------- /drizzle/0021_markers.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."marker_type" AS ENUM('notifications', 'home'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "markers" ( 8 | "account_owner_id" uuid NOT NULL, 9 | "type" "marker_type" NOT NULL, 10 | "last_read_id" text NOT NULL, 11 | "version" bigint DEFAULT 1 NOT NULL, 12 | "updated" timestamp with time zone DEFAULT now() NOT NULL, 13 | CONSTRAINT "markers_account_owner_id_type_pk" PRIMARY KEY("account_owner_id","type") 14 | ); 15 | --> statement-breakpoint 16 | DO $$ BEGIN 17 | ALTER TABLE "markers" ADD CONSTRAINT "markers_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; 21 | -------------------------------------------------------------------------------- /drizzle/0022_media.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "media" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "post_id" uuid, 4 | "type" text NOT NULL, 5 | "url" text NOT NULL, 6 | "width" integer NOT NULL, 7 | "height" integer NOT NULL, 8 | "description" text, 9 | "thumbnail_type" text NOT NULL, 10 | "thumbnail_url" text NOT NULL, 11 | "thumbnail_width" integer NOT NULL, 12 | "thumbnail_height" integer NOT NULL, 13 | "created" timestamp with time zone DEFAULT now() NOT NULL 14 | ); 15 | --> statement-breakpoint 16 | DO $$ BEGIN 17 | ALTER TABLE "media" ADD CONSTRAINT "media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; 21 | -------------------------------------------------------------------------------- /drizzle/0023_ed25519-keys.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" RENAME COLUMN "private_key_jwk" TO "rsa_private_key_jwk";--> statement-breakpoint 2 | ALTER TABLE "account_owners" RENAME COLUMN "public_key_jwk" TO "rsa_public_key_jwk";--> statement-breakpoint 3 | ALTER TABLE "account_owners" ADD COLUMN "ed25519_private_key_jwk" jsonb NOT NULL DEFAULT 'null';--> statement-breakpoint 4 | ALTER TABLE "account_owners" ALTER COLUMN "ed25519_private_key_jwk" DROP DEFAULT;--> statement-breakpoint 5 | ALTER TABLE "account_owners" ADD COLUMN "ed25519_public_key_jwk" jsonb NOT NULL DEFAULT 'null';--> statement-breakpoint 6 | ALTER TABLE "account_owners" ALTER COLUMN "ed25519_public_key_jwk" DROP DEFAULT; 7 | -------------------------------------------------------------------------------- /drizzle/0024_pinned_posts.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "pinned_posts" ( 2 | "index" bigserial PRIMARY KEY NOT NULL, 3 | "post_id" uuid NOT NULL, 4 | "account_id" uuid NOT NULL, 5 | "created" timestamp with time zone DEFAULT now() NOT NULL, 6 | CONSTRAINT "pinned_posts_post_id_account_id_unique" UNIQUE("post_id","account_id") 7 | ); 8 | --> statement-breakpoint 9 | ALTER TABLE "posts" ADD CONSTRAINT "posts_id_actor_id_unique" UNIQUE("id", "actor_id"); 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "pinned_posts" ADD CONSTRAINT "pinned_posts_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | --> statement-breakpoint 17 | DO $$ BEGIN 18 | ALTER TABLE "pinned_posts" ADD CONSTRAINT "pinned_posts_post_id_account_id_posts_id_actor_id_fk" FOREIGN KEY ("post_id","account_id") REFERENCES "public"."posts"("id","actor_id") ON DELETE cascade ON UPDATE no action; 19 | EXCEPTION 20 | WHEN duplicate_object THEN null; 21 | END $$; 22 | -------------------------------------------------------------------------------- /drizzle/0025_accounts.featured_url.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "featured_url" text; -------------------------------------------------------------------------------- /drizzle/0026_featured_tags.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "featured_tags" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_owner_id" uuid NOT NULL, 4 | "name" text NOT NULL, 5 | "created" timestamp with time zone, 6 | CONSTRAINT "featured_tags_account_owner_id_name_unique" UNIQUE("account_owner_id","name") 7 | ); 8 | --> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "featured_tags" ADD CONSTRAINT "featured_tags_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | -------------------------------------------------------------------------------- /drizzle/0027_lists.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."list_replies_policy" AS ENUM('followed', 'list', 'none'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "list_members" ( 8 | "list_id" uuid NOT NULL, 9 | "account_id" uuid NOT NULL, 10 | "created" timestamp with time zone DEFAULT now() NOT NULL, 11 | CONSTRAINT "list_members_list_id_account_id_pk" PRIMARY KEY("list_id","account_id") 12 | ); 13 | --> statement-breakpoint 14 | CREATE TABLE IF NOT EXISTS "lists" ( 15 | "id" uuid PRIMARY KEY NOT NULL, 16 | "account_owner_id" uuid NOT NULL, 17 | "title" text NOT NULL, 18 | "replies_policy" "list_replies_policy" DEFAULT 'list' NOT NULL, 19 | "exclusive" boolean DEFAULT false NOT NULL, 20 | "created" timestamp with time zone DEFAULT now() NOT NULL 21 | ); 22 | --> statement-breakpoint 23 | DO $$ BEGIN 24 | ALTER TABLE "list_members" ADD CONSTRAINT "list_members_list_id_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."lists"("id") ON DELETE cascade ON UPDATE no action; 25 | EXCEPTION 26 | WHEN duplicate_object THEN null; 27 | END $$; 28 | --> statement-breakpoint 29 | DO $$ BEGIN 30 | ALTER TABLE "list_members" ADD CONSTRAINT "list_members_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 31 | EXCEPTION 32 | WHEN duplicate_object THEN null; 33 | END $$; 34 | --> statement-breakpoint 35 | DO $$ BEGIN 36 | ALTER TABLE "lists" ADD CONSTRAINT "lists_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 37 | EXCEPTION 38 | WHEN duplicate_object THEN null; 39 | END $$; 40 | -------------------------------------------------------------------------------- /drizzle/0028_polls.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "poll_options" ( 2 | "poll_id" uuid, 3 | "index" integer NOT NULL, 4 | "title" text NOT NULL, 5 | CONSTRAINT "poll_options_poll_id_index_pk" PRIMARY KEY("poll_id","index"), 6 | CONSTRAINT "poll_options_poll_id_title_unique" UNIQUE("poll_id","title") 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE IF NOT EXISTS "poll_votes" ( 10 | "poll_id" uuid, 11 | "option_index" integer NOT NULL, 12 | "account_id" uuid NOT NULL, 13 | "created" timestamp with time zone DEFAULT now() NOT NULL, 14 | CONSTRAINT "poll_votes_poll_id_option_index_account_id_pk" PRIMARY KEY("poll_id","option_index","account_id") 15 | ); 16 | --> statement-breakpoint 17 | CREATE TABLE IF NOT EXISTS "polls" ( 18 | "id" uuid PRIMARY KEY NOT NULL, 19 | "multiple" boolean DEFAULT false NOT NULL, 20 | "expires" timestamp with time zone NOT NULL, 21 | "created" timestamp with time zone DEFAULT now() NOT NULL 22 | ); 23 | --> statement-breakpoint 24 | ALTER TABLE "posts" ADD COLUMN "poll_id" uuid;--> statement-breakpoint 25 | DO $$ BEGIN 26 | ALTER TABLE "poll_options" ADD CONSTRAINT "poll_options_poll_id_polls_id_fk" FOREIGN KEY ("poll_id") REFERENCES "public"."polls"("id") ON DELETE cascade ON UPDATE no action; 27 | EXCEPTION 28 | WHEN duplicate_object THEN null; 29 | END $$; 30 | --> statement-breakpoint 31 | DO $$ BEGIN 32 | ALTER TABLE "poll_votes" ADD CONSTRAINT "poll_votes_poll_id_polls_id_fk" FOREIGN KEY ("poll_id") REFERENCES "public"."polls"("id") ON DELETE cascade ON UPDATE no action; 33 | EXCEPTION 34 | WHEN duplicate_object THEN null; 35 | END $$; 36 | --> statement-breakpoint 37 | DO $$ BEGIN 38 | ALTER TABLE "poll_votes" ADD CONSTRAINT "poll_votes_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 39 | EXCEPTION 40 | WHEN duplicate_object THEN null; 41 | END $$; 42 | --> statement-breakpoint 43 | DO $$ BEGIN 44 | ALTER TABLE "poll_votes" ADD CONSTRAINT "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk" FOREIGN KEY ("poll_id","option_index") REFERENCES "public"."poll_options"("poll_id","index") ON DELETE no action ON UPDATE no action; 45 | EXCEPTION 46 | WHEN duplicate_object THEN null; 47 | END $$; 48 | --> statement-breakpoint 49 | DO $$ BEGIN 50 | ALTER TABLE "posts" ADD CONSTRAINT "posts_poll_id_polls_id_fk" FOREIGN KEY ("poll_id") REFERENCES "public"."polls"("id") ON DELETE set null ON UPDATE no action; 51 | EXCEPTION 52 | WHEN duplicate_object THEN null; 53 | END $$; 54 | -------------------------------------------------------------------------------- /drizzle/0029_poll_options.votes_count.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "poll_options" ADD COLUMN "votes_count" bigint DEFAULT 0 NOT NULL; -------------------------------------------------------------------------------- /drizzle/0030_polls.voters_count.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "polls" ADD COLUMN "voters_count" bigint DEFAULT 0 NOT NULL; -------------------------------------------------------------------------------- /drizzle/0031_Question_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "post_type" ADD VALUE 'Question'; -------------------------------------------------------------------------------- /drizzle/0032_Make.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "poll_votes" ALTER COLUMN "poll_id" SET NOT NULL; -------------------------------------------------------------------------------- /drizzle/0033_steep_santa_claus.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "posts" ADD COLUMN "quote_target_id" uuid;--> statement-breakpoint 2 | DO $$ BEGIN 3 | ALTER TABLE "posts" ADD CONSTRAINT "posts_quote_target_id_posts_id_fk" FOREIGN KEY ("quote_target_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action; 4 | EXCEPTION 5 | WHEN duplicate_object THEN null; 6 | END $$; 7 | -------------------------------------------------------------------------------- /drizzle/0034_graceful_robbie_robertson.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "mutes" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "muted_account_id" uuid NOT NULL, 5 | "notifications" boolean DEFAULT true NOT NULL, 6 | "duration" integer DEFAULT 0 NOT NULL, 7 | "created" timestamp with time zone DEFAULT now() NOT NULL, 8 | CONSTRAINT "mutes_account_id_muted_account_id_unique" UNIQUE("account_id","muted_account_id") 9 | ); 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "mutes" ADD CONSTRAINT "mutes_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | --> statement-breakpoint 17 | DO $$ BEGIN 18 | ALTER TABLE "mutes" ADD CONSTRAINT "mutes_muted_account_id_accounts_id_fk" FOREIGN KEY ("muted_account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 19 | EXCEPTION 20 | WHEN duplicate_object THEN null; 21 | END $$; 22 | -------------------------------------------------------------------------------- /drizzle/0035_indices.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "bookmarks_post_id_account_owner_id_index" ON "bookmarks" ("post_id","account_owner_id");--> statement-breakpoint 2 | CREATE INDEX IF NOT EXISTS "media_post_id_index" ON "media" ("post_id");--> statement-breakpoint 3 | CREATE INDEX IF NOT EXISTS "poll_votes_poll_id_account_id_index" ON "poll_votes" ("poll_id","account_id");--> statement-breakpoint 4 | CREATE INDEX IF NOT EXISTS "posts_sharing_id_index" ON "posts" ("sharing_id");--> statement-breakpoint 5 | CREATE INDEX IF NOT EXISTS "posts_actor_id_sharing_id_index" ON "posts" ("actor_id","sharing_id");--> statement-breakpoint 6 | CREATE INDEX IF NOT EXISTS "posts_reply_target_id_index" ON "posts" ("reply_target_id"); -------------------------------------------------------------------------------- /drizzle/0036_emojis.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "emojis" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "posts" ADD COLUMN "emojis" jsonb DEFAULT '{}'::jsonb NOT NULL; -------------------------------------------------------------------------------- /drizzle/0037_reactions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "reactions" ( 2 | "post_id" uuid NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "emoji" text NOT NULL, 5 | "custom_emoji" text, 6 | "created" timestamp with time zone DEFAULT now() NOT NULL, 7 | CONSTRAINT "reactions_post_id_account_id_emoji_pk" PRIMARY KEY("post_id","account_id","emoji") 8 | ); 9 | --> statement-breakpoint 10 | DO $$ BEGIN 11 | ALTER TABLE "reactions" ADD CONSTRAINT "reactions_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; 12 | EXCEPTION 13 | WHEN duplicate_object THEN null; 14 | END $$; 15 | --> statement-breakpoint 16 | DO $$ BEGIN 17 | ALTER TABLE "reactions" ADD CONSTRAINT "reactions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; 21 | -------------------------------------------------------------------------------- /drizzle/0038_remove.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "posts" DROP COLUMN IF EXISTS "summary_html"; -------------------------------------------------------------------------------- /drizzle/0039_custom_emojis.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "custom_emojis" ( 2 | "shortcode" text PRIMARY KEY NOT NULL, 3 | "url" text NOT NULL, 4 | "category" text, 5 | "created" timestamp with time zone DEFAULT now() NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /drizzle/0040_emoji_iri.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "reactions" ADD COLUMN "emoji_iri" text; -------------------------------------------------------------------------------- /drizzle/0041_change_mutes_duration_type_to_interval.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "mutes" ALTER COLUMN "duration" DROP NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "mutes" ALTER COLUMN "duration" DROP DEFAULT;--> statement-breakpoint 3 | ALTER TABLE "mutes" ALTER COLUMN "duration" SET DATA TYPE interval USING CASE "duration" WHEN 0 THEN NULL ELSE ("duration" || ' seconds')::interval END; 4 | -------------------------------------------------------------------------------- /drizzle/0042_blocks.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "blocks" ( 2 | "account_id" uuid NOT NULL, 3 | "blocked_account_id" uuid NOT NULL, 4 | "created" timestamp with time zone DEFAULT now() NOT NULL, 5 | CONSTRAINT "blocks_account_id_blocked_account_id_pk" PRIMARY KEY("account_id","blocked_account_id") 6 | ); 7 | -------------------------------------------------------------------------------- /drizzle/0043_accounts.successor_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "successor_id" uuid;--> statement-breakpoint 2 | DO $$ BEGIN 3 | ALTER TABLE "accounts" ADD CONSTRAINT "accounts_successor_id_accounts_id_fk" FOREIGN KEY ("successor_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 4 | EXCEPTION 5 | WHEN duplicate_object THEN null; 6 | END $$; 7 | -------------------------------------------------------------------------------- /drizzle/0044_current_timestamp.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "access_tokens" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 2 | ALTER TABLE "accounts" ALTER COLUMN "updated" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 3 | ALTER TABLE "applications" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 4 | ALTER TABLE "blocks" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 5 | ALTER TABLE "bookmarks" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 6 | ALTER TABLE "credentials" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 7 | ALTER TABLE "custom_emojis" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 8 | ALTER TABLE "follows" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 9 | ALTER TABLE "likes" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 10 | ALTER TABLE "list_members" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 11 | ALTER TABLE "lists" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 12 | ALTER TABLE "markers" ALTER COLUMN "updated" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 13 | ALTER TABLE "media" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 14 | ALTER TABLE "mutes" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 15 | ALTER TABLE "pinned_posts" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 16 | ALTER TABLE "poll_votes" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 17 | ALTER TABLE "polls" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 18 | ALTER TABLE "posts" ALTER COLUMN "updated" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint 19 | ALTER TABLE "reactions" ALTER COLUMN "created" SET DEFAULT CURRENT_TIMESTAMP; -------------------------------------------------------------------------------- /drizzle/0045_accounts.aliases.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "aliases" text[] DEFAULT (ARRAY[]::text[]) NOT NULL; 2 | -------------------------------------------------------------------------------- /drizzle/0046_cascade.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ALTER COLUMN "aliases" SET DEFAULT (ARRAY[]::text[]); 2 | --> statement-breakpoint 3 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_account_owner_id_account_owners_id_fk"; 4 | --> statement-breakpoint 5 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_account_owner_id_account_owners_id_fk" FOREIGN KEY ("account_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 6 | --> statement-breakpoint 7 | ALTER TABLE "mentions" DROP CONSTRAINT "mentions_account_id_accounts_id_fk"; 8 | --> statement-breakpoint 9 | ALTER TABLE "mentions" ADD CONSTRAINT "mentions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 10 | --> statement-breakpoint 11 | ALTER TABLE "likes" DROP CONSTRAINT "likes_account_id_accounts_id_fk"; 12 | --> statement-breakpoint 13 | ALTER TABLE "likes" ADD CONSTRAINT "likes_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 14 | -------------------------------------------------------------------------------- /drizzle/0047_cascade.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | ALTER TABLE "blocks" ADD CONSTRAINT "blocks_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | DO $$ BEGIN 8 | ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_account_id_accounts_id_fk" FOREIGN KEY ("blocked_account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 9 | EXCEPTION 10 | WHEN duplicate_object THEN null; 11 | END $$; 12 | -------------------------------------------------------------------------------- /drizzle/0048_accounts.software.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "accounts" ADD COLUMN "software" text;--> statement-breakpoint 2 | ALTER TABLE "accounts" ADD COLUMN "software_version" text; -------------------------------------------------------------------------------- /drizzle/0049_totps.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "totps" ( 2 | "issuer" text NOT NULL, 3 | "label" text NOT NULL, 4 | "algorithm" text NOT NULL, 5 | "digits" smallint NOT NULL, 6 | "period" smallint NOT NULL, 7 | "secret" text NOT NULL, 8 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 9 | ); 10 | -------------------------------------------------------------------------------- /drizzle/0050_reports.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "reports" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "account_id" uuid NOT NULL, 4 | "target_account_id" uuid NOT NULL, 5 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 6 | "comment" text, 7 | "posts" uuid [] DEFAULT '{}'::uuid [] NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | DO $$ BEGIN 11 | ALTER TABLE "reports" 12 | ADD CONSTRAINT "reports_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | --> statement-breakpoint 17 | DO $$ BEGIN 18 | ALTER TABLE "reports" 19 | ADD CONSTRAINT "reports_target_account_id_accounts_id_fk" FOREIGN KEY ("target_account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 20 | EXCEPTION 21 | WHEN duplicate_object THEN null; 22 | END $$; 23 | -------------------------------------------------------------------------------- /drizzle/0051_change_reports_comment_to_not_null.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "reports" ALTER COLUMN "comment" SET NOT NULL; -------------------------------------------------------------------------------- /drizzle/0052_organic_ravenous.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "reports" ADD COLUMN "iri" text NOT NULL; -------------------------------------------------------------------------------- /drizzle/0053_instances.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "instances" ( 2 | "host" text PRIMARY KEY NOT NULL, 3 | "software" text, 4 | "software_version" text, 5 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | ALTER TABLE "accounts" ADD COLUMN "instance_host" text;--> statement-breakpoint 9 | INSERT INTO "instances" ("host", "software", "software_version") 10 | SELECT 11 | regexp_replace("accounts"."handle", '^@[^@]+@', ''), 12 | any_value("accounts"."software"), 13 | any_value("accounts"."software_version") 14 | FROM "accounts" 15 | GROUP BY regexp_replace("accounts"."handle", '^@[^@]+@', '');--> statement-breakpoint 16 | UPDATE "accounts" SET "instance_host" = regexp_replace("accounts"."handle", '^@[^@]+@', '');--> statement-breakpoint 17 | ALTER TABLE "accounts" ALTER COLUMN "instance_host" SET NOT NULL;--> statement-breakpoint 18 | DO $$ BEGIN 19 | ALTER TABLE "accounts" ADD CONSTRAINT "accounts_instance_host_instances_host_fk" FOREIGN KEY ("instance_host") REFERENCES "public"."instances"("host") ON DELETE no action ON UPDATE no action; 20 | EXCEPTION 21 | WHEN duplicate_object THEN null; 22 | END $$; 23 | --> statement-breakpoint 24 | ALTER TABLE "accounts" DROP COLUMN IF EXISTS "software";--> statement-breakpoint 25 | ALTER TABLE "accounts" DROP COLUMN IF EXISTS "software_version"; 26 | -------------------------------------------------------------------------------- /drizzle/0054_indices.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "likes_account_id_post_id_index" ON "likes" ("account_id","post_id");--> statement-breakpoint 2 | CREATE INDEX IF NOT EXISTS "mentions_post_id_account_id_index" ON "mentions" ("post_id","account_id");--> statement-breakpoint 3 | CREATE INDEX IF NOT EXISTS "pinned_posts_account_id_post_id_index" ON "pinned_posts" ("account_id","post_id");--> statement-breakpoint 4 | CREATE INDEX IF NOT EXISTS "posts_visibility_actor_id_index" ON "posts" ("visibility","actor_id");--> statement-breakpoint 5 | CREATE INDEX IF NOT EXISTS "reactions_post_id_index" ON "reactions" ("post_id"); -------------------------------------------------------------------------------- /drizzle/0055_indices.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "poll_options_poll_id_index_index" ON "poll_options" ("poll_id","index");--> statement-breakpoint 2 | CREATE INDEX IF NOT EXISTS "posts_visibility_actor_id_sharing_id_index" ON "posts" ("visibility","actor_id","sharing_id") WHERE "sharing_id" IS NOT NULL;--> statement-breakpoint 3 | CREATE INDEX IF NOT EXISTS "posts_visibility_actor_id_reply_target_id_index" ON "posts" ("visibility","actor_id","reply_target_id") WHERE "reply_target_id" IS NOT NULL;--> statement-breakpoint 4 | CREATE INDEX IF NOT EXISTS "reactions_post_id_account_id_index" ON "reactions" ("post_id","account_id"); 5 | -------------------------------------------------------------------------------- /drizzle/0056_posts.idempotence_key.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "posts" ADD COLUMN "idempotence_key" text; -------------------------------------------------------------------------------- /drizzle/0057_indices.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "blocks_account_id_index" ON "blocks" ("account_id");--> statement-breakpoint 2 | CREATE INDEX IF NOT EXISTS "blocks_blocked_account_id_index" ON "blocks" ("blocked_account_id");--> statement-breakpoint 3 | CREATE INDEX IF NOT EXISTS "posts_actor_id_index" ON "posts" ("actor_id"); -------------------------------------------------------------------------------- /drizzle/0058_update-drizzle-orm.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX "posts_visibility_actor_id_sharing_id_index";--> statement-breakpoint 2 | DROP INDEX "posts_visibility_actor_id_reply_target_id_index";--> statement-breakpoint 3 | ALTER TABLE "account_owners" ALTER COLUMN "followed_tags" SET DEFAULT '{}';--> statement-breakpoint 4 | CREATE INDEX "posts_visibility_actor_id_sharing_id_index" ON "posts" USING btree ("visibility","actor_id","sharing_id") WHERE "posts"."sharing_id" is not null;--> statement-breakpoint 5 | CREATE INDEX "posts_visibility_actor_id_reply_target_id_index" ON "posts" USING btree ("visibility","actor_id","reply_target_id") WHERE "posts"."reply_target_id" is not null;--> statement-breakpoint 6 | DELETE FROM "follows" WHERE "follows"."following_id" = "follows"."follower_id";--> statement-breakpoint 7 | ALTER TABLE "follows" ADD CONSTRAINT "ck_follows_self" CHECK ("follows"."following_id" != "follows"."follower_id"); 8 | -------------------------------------------------------------------------------- /drizzle/0059_timeline_inboxes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "list_posts" ( 2 | "list_id" uuid NOT NULL, 3 | "post_id" uuid NOT NULL, 4 | CONSTRAINT "list_posts_list_id_post_id_pk" PRIMARY KEY("list_id","post_id") 5 | ); 6 | --> statement-breakpoint 7 | CREATE TABLE "timeline_posts" ( 8 | "account_id" uuid NOT NULL, 9 | "post_id" uuid NOT NULL, 10 | CONSTRAINT "timeline_posts_account_id_post_id_pk" PRIMARY KEY("account_id","post_id") 11 | ); 12 | --> statement-breakpoint 13 | ALTER TABLE "list_posts" ADD CONSTRAINT "list_posts_list_id_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 14 | ALTER TABLE "list_posts" ADD CONSTRAINT "list_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 15 | ALTER TABLE "timeline_posts" ADD CONSTRAINT "timeline_posts_account_id_account_owners_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 16 | ALTER TABLE "timeline_posts" ADD CONSTRAINT "timeline_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 17 | CREATE INDEX "list_posts_list_id_post_id_index" ON "list_posts" USING btree ("list_id","post_id");--> statement-breakpoint 18 | CREATE INDEX "timeline_posts_account_id_post_id_index" ON "timeline_posts" USING btree ("account_id","post_id"); -------------------------------------------------------------------------------- /drizzle/0060_account_owners.discoverable.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_owners" ADD COLUMN "discoverable" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0061_unique-posts.actor_id-posts.sharing_id.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "posts" WHERE "posts"."id" NOT IN ( 2 | SELECT any_value("posts"."id") 3 | FROM "posts" 4 | WHERE "posts"."sharing_id" IS NOT NULL 5 | GROUP BY "posts"."actor_id", "posts"."sharing_id" 6 | ) AND "posts"."sharing_id" IS NOT NULL; 7 | --> statement-breakpoint 8 | ALTER TABLE "posts" ADD CONSTRAINT "posts_actor_id_sharing_id_unique" UNIQUE("actor_id","sharing_id"); 9 | -------------------------------------------------------------------------------- /drizzle/0062_follows_follower_id_accounts_id_fk.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "follows" DROP CONSTRAINT "follows_follower_id_accounts_id_fk"; 2 | --> statement-breakpoint 3 | DELETE FROM "follows" WHERE "follower_id" NOT IN (SELECT "id" FROM "accounts"); 4 | --> statement-breakpoint 5 | ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_accounts_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; 6 | -------------------------------------------------------------------------------- /drizzle/0063_theme_color.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."theme_color" AS ENUM('amber', 'azure', 'blue', 'cyan', 'fuchsia', 'green', 'grey', 'indigo', 'jade', 'lime', 'orange', 'pink', 'pumpkin', 'purple', 'red', 'sand', 'slate', 'violet', 'yellow', 'zinc');--> statement-breakpoint 2 | ALTER TABLE "account_owners" ADD COLUMN "theme_color" "theme_color" NOT NULL DEFAULT 'azure';--> statement-breakpoint 3 | ALTER TABLE "account_owners" ALTER COLUMN "theme_color" DROP DEFAULT; -------------------------------------------------------------------------------- /drizzle/0064_oauth_access_grants.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "access_grants" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "token" text NOT NULL, 4 | "expires_in" integer NOT NULL, 5 | "redirect_uri" text NOT NULL, 6 | "scopes" "scope" [] NOT NULL, 7 | "application_id" uuid NOT NULL, 8 | "resource_owner_id" uuid NOT NULL, 9 | "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 10 | "revoked" timestamp with time zone, 11 | CONSTRAINT "access_grants_token_unique" UNIQUE("token") 12 | ); 13 | --> statement-breakpoint 14 | ALTER TABLE "access_grants" 15 | ADD CONSTRAINT "access_grants_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE cascade ON UPDATE no action; 16 | --> statement-breakpoint 17 | ALTER TABLE "access_grants" 18 | ADD CONSTRAINT "access_grants_resource_owner_id_account_owners_id_fk" FOREIGN KEY ("resource_owner_id") REFERENCES "public"."account_owners"("id") ON DELETE cascade ON UPDATE no action; 19 | --> statement-breakpoint 20 | CREATE INDEX "access_grants_resource_owner_id_index" ON "access_grants" USING btree ("resource_owner_id"); 21 | -------------------------------------------------------------------------------- /drizzle/0065_change_oauth_access_grants_token_to_code.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "access_grants" RENAME COLUMN "token" TO "code";--> statement-breakpoint 2 | ALTER TABLE "access_grants" DROP CONSTRAINT "access_grants_token_unique";--> statement-breakpoint 3 | ALTER TABLE "access_grants" ADD CONSTRAINT "access_grants_code_unique" UNIQUE("code"); -------------------------------------------------------------------------------- /drizzle/0066_add_oauth_application_confidentiality.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "applications" ADD COLUMN "confidential" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /drizzle/0067_add_pkce_to_access_grants.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "access_grants" ADD COLUMN "code_challenge" text;--> statement-breakpoint 2 | ALTER TABLE "access_grants" ADD COLUMN "code_challenge_method" varchar(256); -------------------------------------------------------------------------------- /drizzle/0068_add_profile_scope.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."scope" ADD VALUE 'profile'; -------------------------------------------------------------------------------- /drizzle/0069_repair-application-confidentiality.sql: -------------------------------------------------------------------------------- 1 | UPDATE "applications" 2 | SET confidential = true 3 | WHERE confidential = false; 4 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "tables": { 5 | "public.credentials": { 6 | "name": "credentials", 7 | "schema": "", 8 | "columns": { 9 | "email": { 10 | "name": "email", 11 | "type": "varchar(254)", 12 | "primaryKey": true, 13 | "notNull": true 14 | }, 15 | "password_hash": { 16 | "name": "password_hash", 17 | "type": "text", 18 | "primaryKey": false, 19 | "notNull": true 20 | }, 21 | "created": { 22 | "name": "created", 23 | "type": "timestamp with time zone", 24 | "primaryKey": false, 25 | "notNull": true, 26 | "default": "now()" 27 | } 28 | }, 29 | "indexes": {}, 30 | "foreignKeys": {}, 31 | "compositePrimaryKeys": {}, 32 | "uniqueConstraints": {}, 33 | "policies": {}, 34 | "isRLSEnabled": false, 35 | "checkConstraints": {} 36 | } 37 | }, 38 | "enums": {}, 39 | "schemas": {}, 40 | "_meta": { 41 | "schemas": {}, 42 | "tables": {}, 43 | "columns": {} 44 | }, 45 | "id": "f6a7966d-e4a0-4a79-8618-d2fabc21e93d", 46 | "prevId": "00000000-0000-0000-0000-000000000000", 47 | "sequences": {}, 48 | "policies": {}, 49 | "views": {}, 50 | "roles": {} 51 | } -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "24" 3 | "npm:pnpm" = "latest" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fedify/hollo", 3 | "description": "Federated single-user microblogging software", 4 | "version": "0.7.0", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "prod": "pnpm run migrate && tsx --env-file-if-exists=.env --dns-result-order=ipv6first bin/server.ts", 9 | "dev": "pnpm run migrate && tsx watch --env-file-if-exists=.env --dns-result-order=ipv6first bin/server.ts", 10 | "list:routes": "tsx --env-file-if-exists=.env bin/routes.ts", 11 | "test": "pnpm run migrate:test && vitest", 12 | "test:ci": "vitest", 13 | "check": "tsc && biome check .", 14 | "check:ci": "tsc && biome ci --reporter=github .", 15 | "check:coverage": "pnpm run migrate:test && vitest --coverage", 16 | "migrate": "drizzle-kit migrate", 17 | "migrate:test": "dotenvx run -f .env.test -- drizzle-kit migrate", 18 | "migrate:generate": "drizzle-kit generate", 19 | "rebuild-timelines": "tsx --env-file-if-exists=.env scripts/rebuild-timelines.ts" 20 | }, 21 | "dependencies": { 22 | "@aws-sdk/credential-providers": "^3.716.0", 23 | "@fedify/fedify": "~1.5.3", 24 | "@fedify/markdown-it-hashtag": "~0.2.0", 25 | "@fedify/markdown-it-mention": "~0.1.1", 26 | "@fedify/postgres": "~0.3.0", 27 | "@hexagon/base64": "^2.0.4", 28 | "@hono/node-server": "^1.13.7", 29 | "@hono/zod-validator": "^0.2.2", 30 | "@js-temporal/polyfill": "^0.5.0", 31 | "@logtape/file": "~0.10.0", 32 | "@logtape/logtape": "~0.10.0", 33 | "@logtape/sentry": "^0.1.0", 34 | "@sentry/core": "^8.47.0", 35 | "@sentry/node": "^8.47.0", 36 | "@shikijs/markdown-it": "^3.5.0", 37 | "@supercharge/promise-pool": "^3.2.0", 38 | "argon2": "^0.41.1", 39 | "cheerio": "^1.0.0", 40 | "csv-writer-portable": "^1.7.6", 41 | "drizzle-kit": "^0.30.1", 42 | "drizzle-orm": "^0.38.3", 43 | "es-toolkit": "^1.30.1", 44 | "fluent-ffmpeg": "^2.1.3", 45 | "flydrive": "^1.1.0", 46 | "hono": "^4.6.14", 47 | "iso-639-1": "^3.1.3", 48 | "markdown-it": "^14.1.0", 49 | "markdown-it-replace-link": "^1.2.2", 50 | "mime": "^4.0.6", 51 | "neat-csv": "^7.0.0", 52 | "open-graph-scraper": "^6.8.3", 53 | "otpauth": "^9.3.6", 54 | "postgres": "^3.4.5", 55 | "qrcode": "^1.5.4", 56 | "semver": "^7.6.3", 57 | "sharp": "^0.33.5", 58 | "ssrfcheck": "^1.1.1", 59 | "tsx": "^4.19.2", 60 | "uuidv7-js": "^1.1.4", 61 | "x-forwarded-fetch": "^0.2.0", 62 | "xss": "^1.0.15", 63 | "zod": "^3.24.1" 64 | }, 65 | "devDependencies": { 66 | "@biomejs/biome": "^1.9.4", 67 | "@dotenvx/dotenvx": "^1.37.0", 68 | "@reporters/github": "^1.7.2", 69 | "@types/fluent-ffmpeg": "^2.1.27", 70 | "@types/markdown-it": "^14.1.2", 71 | "@types/node": "^22.13.4", 72 | "@types/qrcode": "^1.5.5", 73 | "@types/semver": "^7.5.8", 74 | "@vitest/coverage-v8": "3.1.4", 75 | "dotenv": "^16.5.0", 76 | "linkedom": "^0.18.10", 77 | "timekeeper": "^2.3.1", 78 | "typescript": "^5.7.2", 79 | "vitest": "^3.1.4" 80 | }, 81 | "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" 82 | } 83 | -------------------------------------------------------------------------------- /scripts/rebuild-timelines.ts: -------------------------------------------------------------------------------- 1 | import "../src/logging"; 2 | import db from "../src/db"; 3 | import { rebuildTimelines } from "../src/federation/timeline"; 4 | 5 | await rebuildTimelines(db); 6 | process.exit(); 7 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { cors } from "hono/cors"; 3 | import v1 from "./v1"; 4 | import v2 from "./v2"; 5 | 6 | const app = new Hono(); 7 | 8 | app.use( 9 | cors({ 10 | origin: "*", 11 | allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"], 12 | exposeHeaders: ["Link"], 13 | }), 14 | ); 15 | app.route("/v1", v1); 16 | app.route("/v2", v2); 17 | 18 | export default app; 19 | -------------------------------------------------------------------------------- /src/api/v1/instance.ts: -------------------------------------------------------------------------------- 1 | import { and, inArray, isNotNull } from "drizzle-orm"; 2 | import { Hono } from "hono"; 3 | import metadata from "../../../package.json" with { type: "json" }; 4 | import { db } from "../../db"; 5 | import { serializeAccountOwner } from "../../entities/account"; 6 | import { accountOwners, posts } from "../../schema"; 7 | 8 | const app = new Hono(); 9 | 10 | app.get("/", async (c) => { 11 | const url = new URL(c.req.url); 12 | const credential = await db.query.credentials.findFirst(); 13 | if (credential == null) return c.notFound(); 14 | const accountOwner = await db.query.accountOwners.findFirst({ 15 | with: { account: { with: { successor: true } } }, 16 | orderBy: accountOwners.id, 17 | }); 18 | if (accountOwner == null) return c.notFound(); 19 | const languages = await db 20 | .select({ language: posts.language }) 21 | .from(posts) 22 | .where( 23 | and( 24 | isNotNull(posts.language), 25 | inArray( 26 | posts.accountId, 27 | db.select({ id: accountOwners.id }).from(accountOwners), 28 | ), 29 | ), 30 | ) 31 | .groupBy(posts.language); 32 | return c.json({ 33 | uri: url.host, 34 | title: url.host, 35 | short_description: `A Hollo instance at ${url.host}`, 36 | description: `A Hollo instance at ${url.host}`, 37 | email: credential.email, 38 | version: metadata.version, 39 | urls: {}, // TODO 40 | stats: { 41 | user_count: 0, // TODO 42 | status_count: 0, // TODO 43 | domain_count: 0, // TODO 44 | }, 45 | thumbnail: null, // TODO 46 | languages: languages.map(({ language }) => language), 47 | registrations: false, 48 | approval_required: true, 49 | invites_enabled: false, 50 | configuration: { 51 | statuses: { 52 | // TODO 53 | max_characters: 10000, 54 | max_media_attachments: 8, 55 | characters_reserved_per_url: 256, 56 | }, 57 | media_attachments: { 58 | supported_mime_types: [ 59 | "image/jpeg", 60 | "image/png", 61 | "image/gif", 62 | "image/webp", 63 | "video/mp4", 64 | "video/webm", 65 | ], 66 | image_size_limit: 1024 * 1024 * 32, // 32MiB 67 | image_matrix_limit: 16_777_216, 68 | // TODO 69 | video_size_limit: 1024 * 1024 * 128, // 128MiB 70 | video_frame_rate_limit: 120, 71 | video_matrix_limit: 16_777_216, 72 | }, 73 | polls: { 74 | max_options: 10, 75 | max_characters_per_option: 100, 76 | min_expiration: 60 * 5, 77 | max_expiration: 60 * 60 * 24 * 14, 78 | }, 79 | }, 80 | contact_account: serializeAccountOwner(accountOwner, c.req.url), 81 | rules: [], 82 | feature_quote: true, 83 | fedibird_capabilities: [ 84 | "emoji_reaction", 85 | "enable_wide_emoji", 86 | "enable_wide_emoji_reaction", 87 | ], 88 | }); 89 | }); 90 | 91 | export default app; 92 | -------------------------------------------------------------------------------- /src/api/v1/markers.ts: -------------------------------------------------------------------------------- 1 | import { zValidator } from "@hono/zod-validator"; 2 | import { eq, sql } from "drizzle-orm"; 3 | import { Hono } from "hono"; 4 | import { z } from "zod"; 5 | import { db } from "../../db"; 6 | import { serializeMarkers } from "../../entities/marker"; 7 | import { 8 | type Variables, 9 | scopeRequired, 10 | tokenRequired, 11 | } from "../../oauth/middleware"; 12 | import { type MarkerType, type NewMarker, markers } from "../../schema"; 13 | 14 | const app = new Hono<{ Variables: Variables }>(); 15 | 16 | app.get("/", tokenRequired, scopeRequired(["read:statuses"]), async (c) => { 17 | const owner = c.get("token").accountOwner; 18 | if (owner == null) { 19 | return c.json({ error: "This method requires an authenticated user" }, 422); 20 | } 21 | const markerList = await db.query.markers.findMany({ 22 | where: eq(markers.accountOwnerId, owner.id), 23 | }); 24 | return c.json(serializeMarkers(markerList)); 25 | }); 26 | 27 | app.post( 28 | "/", 29 | tokenRequired, 30 | scopeRequired(["write:statuses"]), 31 | zValidator( 32 | "json", 33 | z.record( 34 | z.enum(["notifications", "home"]), 35 | z.object({ 36 | last_read_id: z.string(), 37 | }), 38 | ), 39 | ), 40 | async (c) => { 41 | const owner = c.get("token").accountOwner; 42 | if (owner == null) { 43 | return c.json( 44 | { error: "This method requires an authenticated user" }, 45 | 422, 46 | ); 47 | } 48 | const payload = c.req.valid("json"); 49 | await db.transaction(async (tx) => { 50 | for (const key in payload) { 51 | const markerType = key as MarkerType; 52 | const lastReadId = payload[markerType]?.last_read_id; 53 | if (lastReadId == null) continue; 54 | await tx 55 | .insert(markers) 56 | .values({ 57 | type: markerType, 58 | accountOwnerId: owner.id, 59 | lastReadId, 60 | } satisfies NewMarker) 61 | .onConflictDoUpdate({ 62 | set: { 63 | lastReadId, 64 | version: sql`${markers.version} + 1`, 65 | updated: sql`now()`, 66 | }, 67 | target: [markers.accountOwnerId, markers.type], 68 | }); 69 | } 70 | }); 71 | const markerList = await db.query.markers.findMany({ 72 | where: eq(markers.accountOwnerId, owner.id), 73 | }); 74 | return c.json(serializeMarkers(markerList)); 75 | }, 76 | ); 77 | 78 | export default app; 79 | -------------------------------------------------------------------------------- /src/api/v1/tags.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { Hono } from "hono"; 3 | import { db } from "../../db"; 4 | import { serializeTag } from "../../entities/tag"; 5 | import { 6 | type Variables, 7 | scopeRequired, 8 | tokenRequired, 9 | } from "../../oauth/middleware"; 10 | import { accountOwners } from "../../schema"; 11 | 12 | const app = new Hono<{ Variables: Variables }>(); 13 | 14 | app.use(tokenRequired); 15 | 16 | app.get("/:id", (c) => { 17 | const owner = c.get("token").accountOwner; 18 | const tag = c.req.param("id"); 19 | return c.json(serializeTag(tag, owner, c.req.url)); 20 | }); 21 | 22 | app.post("/:id/follow", scopeRequired(["write:follows"]), async (c) => { 23 | const owner = c.get("token").accountOwner; 24 | if (owner == null) { 25 | return c.json({ error: "This method requires an authenticated user" }, 422); 26 | } 27 | const tag = c.req.param("id"); 28 | await db.update(accountOwners).set({ 29 | followedTags: sql`array_append(${accountOwners.followedTags}, ${tag})`, 30 | }); 31 | return c.json({ ...serializeTag(tag, null, c.req.url), following: true }); 32 | }); 33 | 34 | app.post("/:id/unfollow", scopeRequired(["write:follows"]), async (c) => { 35 | const owner = c.get("token").accountOwner; 36 | if (owner == null) { 37 | return c.json({ error: "This method requires an authenticated user" }, 422); 38 | } 39 | const tag = c.req.param("id"); 40 | await db.update(accountOwners).set({ 41 | followedTags: sql`array_remove(${accountOwners.followedTags}, ${tag})`, 42 | }); 43 | return c.json({ ...serializeTag(tag, null, c.req.url), following: false }); 44 | }); 45 | 46 | export default app; 47 | -------------------------------------------------------------------------------- /src/components/AccountList.tsx: -------------------------------------------------------------------------------- 1 | import { escape } from "es-toolkit"; 2 | import xss from "xss"; 3 | import type { Account, AccountOwner } from "../schema"; 4 | import { renderCustomEmojis } from "../text"; 5 | 6 | export interface AccountListProps { 7 | accountOwners: (AccountOwner & { account: Account })[]; 8 | } 9 | 10 | export function AccountList({ accountOwners }: AccountListProps) { 11 | return ( 12 | <> 13 | {accountOwners.map((account) => ( 14 | 15 | ))} 16 | 17 | ); 18 | } 19 | 20 | interface AccountItemProps { 21 | accountOwner: AccountOwner & { account: Account }; 22 | } 23 | 24 | function AccountItem({ accountOwner: { account } }: AccountItemProps) { 25 | const nameHtml = renderCustomEmojis(escape(account.name), account.emojis); 26 | const bioHtml = renderCustomEmojis( 27 | xss(account.bioHtml ?? ""), 28 | account.emojis, 29 | ); 30 | const href = account.url ?? account.iri; 31 | return ( 32 |
33 |
34 |
35 |

36 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: xss protected */} 37 | 38 |

39 |

{account.handle}

40 |
41 |
42 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: xss protected */} 43 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/DashboardLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "hono/jsx"; 2 | import metadata from "../../package.json"; 3 | import { Layout, type LayoutProps } from "./Layout"; 4 | 5 | export type Menu = "accounts" | "emojis" | "federation" | "auth"; 6 | 7 | export interface DashboardLayoutProps extends LayoutProps { 8 | selectedMenu?: Menu; 9 | } 10 | 11 | export function DashboardLayout( 12 | props: PropsWithChildren, 13 | ) { 14 | return ( 15 | 16 |
17 | 81 |
82 | {props.children} 83 |
84 |

85 | Hollo 86 |
87 | Version {metadata.version} 88 |

89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "hono/jsx"; 2 | import type { ThemeColor } from "../schema"; 3 | 4 | export interface LayoutProps { 5 | title: string; 6 | shortTitle?: string | null; 7 | url?: string | null; 8 | description?: string | null; 9 | imageUrl?: string | null; 10 | links?: { href: string | URL; rel: string; type?: string }[]; 11 | themeColor?: ThemeColor; 12 | } 13 | 14 | export function Layout(props: PropsWithChildren) { 15 | const themeColor = props.themeColor ?? "azure"; 16 | return ( 17 | 18 | 19 | 20 | 21 | {props.title} 22 | 23 | {props.description && ( 24 | <> 25 | 26 | 27 | 28 | )} 29 | {props.url && ( 30 | <> 31 | 32 | 33 | 34 | )} 35 | {props.imageUrl && ( 36 | 37 | )} 38 | {props.links?.map((link) => ( 39 | 44 | ))} 45 | 46 | 47 | 48 | 55 | 62 | 63 | 64 |
{props.children}
65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | export interface LoginFormProps { 2 | method?: "get" | "post" | "dialog"; 3 | action: string; 4 | next?: string; 5 | values?: { 6 | email?: string; 7 | }; 8 | errors?: { 9 | email?: string; 10 | password?: string; 11 | }; 12 | } 13 | 14 | export function LoginForm(props: LoginFormProps) { 15 | return ( 16 |
17 | 29 | 40 | {props.next && } 41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/NewAccountPage.tsx: -------------------------------------------------------------------------------- 1 | import type { PostVisibility, ThemeColor } from "../schema.ts"; 2 | import { AccountForm } from "./AccountForm.tsx"; 3 | import { DashboardLayout } from "./DashboardLayout.tsx"; 4 | 5 | export interface NewAccountPageProps { 6 | values?: { 7 | username?: string; 8 | name?: string; 9 | bio?: string; 10 | protected?: boolean; 11 | discoverable?: boolean; 12 | language?: string; 13 | visibility?: PostVisibility; 14 | themeColor?: ThemeColor; 15 | news?: boolean; 16 | }; 17 | errors?: { 18 | username?: string; 19 | name?: string; 20 | bio?: string; 21 | }; 22 | officialAccount: string; 23 | } 24 | 25 | export function NewAccountPage(props: NewAccountPageProps) { 26 | return ( 27 | 28 |
29 |

Create a new account

30 |

You can create a new account by filling out the form below.

31 |
32 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/OtpForm.tsx: -------------------------------------------------------------------------------- 1 | export interface OtpFormProps { 2 | method?: "get" | "post" | "dialog"; 3 | action: string; 4 | next?: string; 5 | errors?: { 6 | token?: string; 7 | }; 8 | } 9 | 10 | export function OtpForm(props: OtpFormProps) { 11 | return ( 12 |
13 |
14 | 23 | 24 |
25 | {props.errors?.token && {props.errors.token}} 26 | {props.next && } 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { escape } from "es-toolkit"; 2 | import type { Account, AccountOwner } from "../schema"; 3 | import { renderCustomEmojis } from "../text"; 4 | 5 | export interface ProfileProps { 6 | accountOwner: AccountOwner & { account: Account }; 7 | } 8 | 9 | export function Profile({ accountOwner }: ProfileProps) { 10 | const account = accountOwner.account; 11 | const nameHtml = renderCustomEmojis(escape(account.name), account.emojis); 12 | const bioHtml = renderCustomEmojis(account.bioHtml ?? "", account.emojis); 13 | const url = account.url ?? account.iri; 14 | return ( 15 |
16 | {account.coverUrl && ( 17 | 22 | )} 23 |
24 | {account.avatarUrl && ( 25 | {`${account.name}'s 32 | )} 33 |

34 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: xss protected */} 35 | 36 |

37 |

38 | 43 | {account.handle} 44 | {" "} 45 | · {`${account.followingCount} following `} 46 | ·{" "} 47 | {account.followersCount === 1 48 | ? "1 follower" 49 | : `${account.followersCount} followers`} 50 |

51 |
52 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: no xss */} 53 |
54 | {account.fieldHtmls && ( 55 |
56 | 57 | 58 | 59 | {Object.keys(account.fieldHtmls).map((key) => ( 60 | 61 | ))} 62 | 63 | 64 | 65 | 66 | {Object.values(account.fieldHtmls).map((value) => ( 67 | 73 | 74 |
{key}
71 | ))} 72 |
75 |
76 | )} 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/SetupForm.tsx: -------------------------------------------------------------------------------- 1 | export interface SetupFormProps { 2 | method?: "get" | "post" | "dialog"; 3 | action: string; 4 | values?: { 5 | email?: string; 6 | }; 7 | errors?: { 8 | email?: string; 9 | password?: string; 10 | passwordConfirm?: string; 11 | }; 12 | } 13 | 14 | export function SetupForm(props: SetupFormProps) { 15 | return ( 16 |
17 |
18 | 34 |
35 |
36 | 51 | 68 |
69 | 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@logtape/logtape"; 2 | import type { ExtractTablesWithRelations } from "drizzle-orm"; 3 | import type { Logger } from "drizzle-orm/logger"; 4 | import type { PgDatabase, PgTransaction } from "drizzle-orm/pg-core"; 5 | import { 6 | type PostgresJsQueryResultHKT, 7 | drizzle, 8 | } from "drizzle-orm/postgres-js"; 9 | import createPostgres from "postgres"; 10 | import * as schema from "./schema"; 11 | 12 | // biome-ignore lint/complexity/useLiteralKeys: tsc rants about this (TS4111) 13 | const databaseUrl = process.env["DATABASE_URL"]; 14 | if (databaseUrl == null) throw new Error("DATABASE_URL must be defined"); 15 | 16 | class LogTapeLogger implements Logger { 17 | readonly logger = getLogger("drizzle-orm"); 18 | 19 | logQuery(query: string, params: unknown[]): void { 20 | const stringifiedParams = params.map(LogTapeLogger.serialize); 21 | const formattedQuery = query.replace(/\$(\d+)/g, (m) => { 22 | const index = Number.parseInt(m.slice(1), 10); 23 | return stringifiedParams[index - 1]; 24 | }); 25 | this.logger.debug("Query: {formattedQuery}", { 26 | formattedQuery, 27 | query, 28 | params, 29 | }); 30 | } 31 | 32 | static serialize(p: unknown): string { 33 | if (typeof p === "undefined" || p === null) return "NULL"; 34 | if (typeof p === "string") return LogTapeLogger.stringLiteral(p); 35 | if (typeof p === "number" || typeof p === "bigint") return p.toString(); 36 | if (typeof p === "boolean") return p ? "'t'" : "'f'"; 37 | if (p instanceof Date) return LogTapeLogger.stringLiteral(p.toISOString()); 38 | if (Array.isArray(p)) { 39 | return `ARRAY[${p.map(LogTapeLogger.serialize).join(", ")}]`; 40 | } 41 | if (typeof p === "object") { 42 | // Assume it's a JSON object 43 | return LogTapeLogger.stringLiteral(JSON.stringify(p)); 44 | } 45 | return LogTapeLogger.stringLiteral(String(p)); 46 | } 47 | 48 | static stringLiteral(s: string) { 49 | if (/\\'\n\r\t\b\f/.exec(s)) { 50 | let str = s; 51 | str = str.replaceAll("\\", "\\\\"); 52 | str = str.replaceAll("'", "\\'"); 53 | str = str.replaceAll("\n", "\\n"); 54 | str = str.replaceAll("\r", "\\r"); 55 | str = str.replaceAll("\t", "\\t"); 56 | str = str.replaceAll("\b", "\\b"); 57 | str = str.replaceAll("\f", "\\f"); 58 | return `E'${str}'`; 59 | } 60 | return `'${s}'`; 61 | } 62 | } 63 | 64 | export const postgres = createPostgres(databaseUrl, { 65 | connect_timeout: 5, 66 | connection: { IntervalStyle: "iso_8601" }, 67 | }); 68 | export const db = drizzle(postgres, { schema, logger: new LogTapeLogger() }); 69 | 70 | export type Database = PgDatabase< 71 | PostgresJsQueryResultHKT, 72 | typeof schema, 73 | ExtractTablesWithRelations 74 | >; 75 | 76 | // This is necessary for passing a transaction into a function: 77 | export type Transaction = PgTransaction< 78 | PostgresJsQueryResultHKT, 79 | typeof schema, 80 | ExtractTablesWithRelations 81 | >; 82 | 83 | export default db; 84 | -------------------------------------------------------------------------------- /src/entities/emoji.ts: -------------------------------------------------------------------------------- 1 | import type { Account, Reaction } from "../schema"; 2 | 3 | export function serializeEmojis( 4 | emojis: Record, 5 | ): Record[] { 6 | return Object.entries(emojis).map(([name, href]) => 7 | serializeEmoji(name, href), 8 | ); 9 | } 10 | 11 | export function serializeEmoji( 12 | name: string, 13 | href: string, 14 | ): Record { 15 | return { 16 | shortcode: name.replace(/(^:)|(:$)/g, ""), 17 | url: href, 18 | static_url: href, 19 | visible_in_picker: false, 20 | category: null, 21 | }; 22 | } 23 | 24 | export function serializeReaction( 25 | reaction: Reaction & { account: Account }, 26 | currentAccountOwner: { id: string } | undefined | null, 27 | ): Record { 28 | const [result] = serializeReactions([reaction], currentAccountOwner); 29 | return result; 30 | } 31 | 32 | export function serializeReactions( 33 | reactions: (Reaction & { account: Account })[], 34 | currentAccountOwner: { id: string } | undefined | null, 35 | ): Record[] { 36 | const result: Record< 37 | string, 38 | { count: number; account_ids: string[]; me: boolean } & Record< 39 | string, 40 | unknown 41 | > 42 | > = {}; 43 | for (const reaction of reactions) { 44 | const domain = 45 | reaction.customEmoji == null 46 | ? null 47 | : reaction.account.handle.replace(/^@?[^@]+@/, ""); 48 | const key = 49 | reaction.customEmoji == null 50 | ? reaction.emoji 51 | : `${reaction.emoji}\n${domain}`; 52 | const me = 53 | currentAccountOwner != null && 54 | reaction.account.id === currentAccountOwner.id; 55 | if (key in result) { 56 | result[key].count++; 57 | result[key].me ||= me; 58 | result[key].account_ids.push(reaction.account.id); 59 | } else { 60 | result[key] = 61 | reaction.customEmoji == null 62 | ? { 63 | name: reaction.emoji, 64 | me, 65 | count: 1, 66 | account_ids: [reaction.account.id], 67 | } 68 | : { 69 | name: reaction.emoji.replace(/(^:)|(:$)/g, ""), 70 | domain, 71 | url: reaction.customEmoji, 72 | static_url: reaction.customEmoji, 73 | me, 74 | count: 1, 75 | account_ids: [reaction.account.id], 76 | }; 77 | } 78 | } 79 | return Object.values(result); 80 | } 81 | -------------------------------------------------------------------------------- /src/entities/list.ts: -------------------------------------------------------------------------------- 1 | import type { List } from "../schema"; 2 | 3 | export function serializeList(list: List) { 4 | return { 5 | id: list.id, 6 | title: list.title, 7 | replies_policy: list.repliesPolicy, 8 | exclusive: list.exclusive, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/entities/marker.ts: -------------------------------------------------------------------------------- 1 | import type { Marker, MarkerType } from "../schema"; 2 | 3 | export function serializeMarker(marker: Marker) { 4 | return { 5 | last_read_id: marker.lastReadId, 6 | version: marker.version, 7 | updated_at: marker.updated.toISOString(), 8 | }; 9 | } 10 | 11 | export function serializeMarkers(markers: Marker[]) { 12 | const result: Partial< 13 | Record> 14 | > = {}; 15 | for (const marker of markers) { 16 | result[marker.type] = serializeMarker(marker); 17 | } 18 | return result; 19 | } 20 | -------------------------------------------------------------------------------- /src/entities/medium.ts: -------------------------------------------------------------------------------- 1 | import type { Medium } from "../schema"; 2 | 3 | // biome-ignore lint/suspicious/noExplicitAny: JSON 4 | export function serializeMedium(medium: Medium): Record { 5 | return { 6 | id: medium.id, 7 | type: medium.type.replace(/\/.*$/, ""), 8 | url: medium.url, 9 | preview_url: medium.thumbnailUrl, 10 | remote_url: null, 11 | text_url: null, 12 | meta: { 13 | original: { 14 | width: medium.width, 15 | height: medium.height, 16 | size: `${medium.width}x${medium.height}`, 17 | aspect: medium.width / medium.height, 18 | }, 19 | small: { 20 | width: medium.thumbnailWidth, 21 | height: medium.thumbnailHeight, 22 | size: `${medium.thumbnailWidth}x${medium.thumbnailHeight}`, 23 | aspect: medium.thumbnailWidth / medium.thumbnailHeight, 24 | }, 25 | focus: { x: 0, y: 0 }, 26 | }, 27 | description: medium.description, 28 | blurhash: null, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/entities/poll.ts: -------------------------------------------------------------------------------- 1 | import type { Poll, PollOption, PollVote } from "../schema"; 2 | 3 | export function serializePoll( 4 | poll: Poll & { 5 | options: PollOption[]; 6 | votes: PollVote[]; 7 | }, 8 | currentAccountOwner: { id: string } | undefined | null, 9 | // biome-ignore lint/suspicious/noExplicitAny: JSON 10 | ): Record { 11 | return { 12 | id: poll.id, 13 | expires_at: poll.expires.toISOString(), 14 | expired: poll.expires <= new Date(), 15 | multiple: poll.multiple, 16 | votes_count: poll.options.reduce( 17 | (acc, option) => acc + option.votesCount, 18 | 0, 19 | ), 20 | voters_count: poll.multiple ? poll.votersCount : null, 21 | voted: 22 | currentAccountOwner != null && 23 | poll.votes.some((v) => v.accountId === currentAccountOwner.id), 24 | own_votes: 25 | currentAccountOwner == null 26 | ? [] 27 | : poll.votes 28 | .filter((v) => v.accountId === currentAccountOwner.id) 29 | .map((v) => v.optionIndex), 30 | options: poll.options 31 | .toSorted((a, b) => (a.index < b.index ? -1 : 1)) 32 | .map(serializePollOption), 33 | emojis: [], // TODO 34 | }; 35 | } 36 | 37 | // biome-ignore lint/suspicious/noExplicitAny: JSON 38 | export function serializePollOption(option: PollOption): Record { 39 | return { 40 | title: option.title, 41 | votes_count: option.votesCount, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/entities/report.ts: -------------------------------------------------------------------------------- 1 | import type { Account, Report } from "../schema"; 2 | import { serializeAccount } from "./account"; 3 | 4 | export function serializeReport( 5 | report: Report, 6 | targetAccount: Account & { successor: Account | null }, 7 | baseUrl: URL | string, 8 | // biome-ignore lint/suspicious/noExplicitAny: JSON 9 | ): Record { 10 | return { 11 | id: report.id, 12 | comment: report.comment, 13 | created_at: report.created, 14 | target_account: serializeAccount(targetAccount, baseUrl), 15 | status_ids: Array.isArray(report.posts) ? report.posts : [], 16 | // Additional properties in the Mastodon API which don't make sense on Hollo 17 | // just yet as we're not receiving reports, but we always forward them 18 | forwarded: true, 19 | category: "other", 20 | rule_ids: null, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/entities/tag.ts: -------------------------------------------------------------------------------- 1 | import type { FeaturedTag } from "../schema"; 2 | 3 | export function serializeTag( 4 | tag: string, 5 | currentAccountOwner: { followedTags?: string[] } | null | undefined, 6 | baseUrl: URL | string, 7 | ) { 8 | return { 9 | name: tag, 10 | url: new URL(`/tags/${encodeURIComponent(tag)}`, baseUrl).href, 11 | history: [], 12 | following: currentAccountOwner?.followedTags?.includes(tag) ?? false, 13 | }; 14 | } 15 | 16 | export function serializeFeaturedTag( 17 | featuredTag: FeaturedTag, 18 | stats: { posts: number; lastPublished: Date | null } | undefined, 19 | baseUrl: URL | string, 20 | ) { 21 | return { 22 | id: featuredTag.id, 23 | name: featuredTag.name, 24 | url: new URL(`/tags/${encodeURIComponent(featuredTag.name)}`, baseUrl).href, 25 | statuses_count: stats?.posts ?? 0, 26 | last_status_at: stats?.lastPublished?.toISOString() ?? null, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | const SECRET_KEY_MINIMUM_LENGTH = 44; 2 | 3 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 4 | const secretKey = process.env["SECRET_KEY"]; 5 | 6 | if (typeof secretKey !== "string") { 7 | throw new Error("SECRET_KEY is required"); 8 | } 9 | 10 | if (secretKey.length < SECRET_KEY_MINIMUM_LENGTH) { 11 | throw new Error( 12 | `SECRET_KEY is too short, received: ${secretKey.length}, expected: ${SECRET_KEY_MINIMUM_LENGTH}`, 13 | ); 14 | } 15 | 16 | export const SECRET_KEY = secretKey; 17 | -------------------------------------------------------------------------------- /src/federation/collection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Object as APObject, 3 | type Collection, 4 | type DocumentLoader, 5 | Link, 6 | traverseCollection, 7 | } from "@fedify/fedify"; 8 | 9 | export interface IterateCollectionOptions { 10 | documentLoader?: DocumentLoader; 11 | contextLoader?: DocumentLoader; 12 | suppressError?: boolean; 13 | } 14 | 15 | export async function* iterateCollection( 16 | collection: Collection, 17 | options?: IterateCollectionOptions, 18 | ): AsyncIterable { 19 | for await (const item of traverseCollection(collection, options)) { 20 | if (item instanceof Link) continue; 21 | yield item; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/federation/date.ts: -------------------------------------------------------------------------------- 1 | import { Temporal } from "@js-temporal/polyfill"; 2 | 3 | export function toTemporalInstant(value: Date): Temporal.Instant; 4 | export function toTemporalInstant(value: null): null; 5 | export function toTemporalInstant(value: Date | null): Temporal.Instant | null; 6 | export function toTemporalInstant(value: Date | null): Temporal.Instant | null { 7 | return value == null ? null : Temporal.Instant.from(value.toISOString()); 8 | } 9 | 10 | export function toDate(value: Temporal.Instant): Date; 11 | export function toDate(value: null): null; 12 | export function toDate(value: Temporal.Instant | null): Date | null; 13 | export function toDate(value: Temporal.Instant | null): Date | null { 14 | return value == null ? value : new Date(value.toString()); 15 | } 16 | -------------------------------------------------------------------------------- /src/federation/emoji.ts: -------------------------------------------------------------------------------- 1 | import { type Context, Emoji, Image } from "@fedify/fedify"; 2 | 3 | interface CustomEmoji { 4 | shortcode: string; 5 | url: string; 6 | } 7 | 8 | export function toEmoji(ctx: Context, emoji: CustomEmoji): Emoji { 9 | const shortcode = emoji.shortcode.replace(/^:|:$/g, ""); 10 | return new Emoji({ 11 | id: ctx.getObjectUri(Emoji, { shortcode }), 12 | name: `:${shortcode}:`, 13 | icon: new Image({ url: new URL(emoji.url) }), 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/federation/federation.ts: -------------------------------------------------------------------------------- 1 | import { ParallelMessageQueue, createFederation } from "@fedify/fedify"; 2 | import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres"; 3 | import metadata from "../../package.json" with { type: "json" }; 4 | import { postgres } from "../db"; 5 | 6 | export const federation = createFederation({ 7 | kv: new PostgresKvStore(postgres), 8 | queue: new ParallelMessageQueue(new PostgresMessageQueue(postgres), 10), 9 | userAgent: { 10 | software: `Hollo/${metadata.version}`, 11 | }, 12 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 13 | allowPrivateAddress: process.env["ALLOW_PRIVATE_ADDRESS"] === "true", 14 | }); 15 | 16 | export default federation; 17 | -------------------------------------------------------------------------------- /src/federation/nodeinfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | count, 3 | countDistinct, 4 | eq, 5 | gt, 6 | isNotNull, 7 | isNull, 8 | sql, 9 | } from "drizzle-orm"; 10 | import { parse } from "semver"; 11 | import metadata from "../../package.json" with { type: "json" }; 12 | import { db } from "../db"; 13 | import { accountOwners, posts } from "../schema"; 14 | import { federation } from "./federation"; 15 | 16 | federation.setNodeInfoDispatcher("/nodeinfo/2.1", async (_ctx) => { 17 | const version = parse(metadata.version)!; 18 | const [{ total }] = await db.select({ total: count() }).from(accountOwners); 19 | const [{ activeMonth }] = await db 20 | .select({ activeMonth: countDistinct(accountOwners.id) }) 21 | .from(accountOwners) 22 | .rightJoin(posts, eq(accountOwners.id, posts.accountId)) 23 | .where(gt(posts.updated, sql`CURRENT_TIMESTAMP - INTERVAL '1 month'`)); 24 | const [{ activeHalfyear }] = await db 25 | .select({ activeHalfyear: countDistinct(accountOwners.id) }) 26 | .from(accountOwners) 27 | .rightJoin(posts, eq(accountOwners.id, posts.accountId)) 28 | .where(gt(posts.updated, sql`CURRENT_TIMESTAMP - INTERVAL '6 months'`)); 29 | const [{ localPosts }] = await db 30 | .select({ localPosts: countDistinct(posts.id) }) 31 | .from(posts) 32 | .rightJoin(accountOwners, eq(posts.accountId, accountOwners.id)) 33 | .where(isNull(posts.replyTargetId)); 34 | const [{ localComments }] = await db 35 | .select({ localComments: countDistinct(posts.id) }) 36 | .from(posts) 37 | .rightJoin(accountOwners, eq(posts.accountId, accountOwners.id)) 38 | .where(isNotNull(posts.replyTargetId)); 39 | return { 40 | software: { 41 | name: "hollo", 42 | version: { 43 | major: version.major, 44 | minor: version.minor, 45 | patch: version.patch, 46 | build: version.build == null ? undefined : [...version.build], 47 | prerelease: 48 | version.prerelease == null ? undefined : [...version.prerelease], 49 | }, 50 | homepage: new URL("https://docs.hollo.social/"), 51 | repository: new URL("https://github.com/fedify-dev/hollo"), 52 | }, 53 | protocols: ["activitypub"], 54 | services: { 55 | outbound: ["atom1.0"], 56 | }, 57 | usage: { 58 | users: { 59 | total, 60 | activeMonth, 61 | activeHalfyear, 62 | }, 63 | localComments, 64 | localPosts, 65 | }, 66 | }; 67 | }); 68 | 69 | // cSpell: ignore halfyear 70 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { base64Url, randomBytes } from "./helpers"; 3 | 4 | import { URL_SAFE_REGEXP } from "./helpers"; 5 | 6 | describe("Helpers", () => { 7 | describe("base64Url", () => { 8 | it("returns a URL safe string", () => { 9 | expect.assertions(2); 10 | 11 | const encoder = new TextEncoder(); 12 | const value = encoder.encode("test").buffer as ArrayBuffer; 13 | const result = base64Url(value); 14 | 15 | expect(result).to.match(URL_SAFE_REGEXP); 16 | expect(result).toBe("dGVzdA"); 17 | }); 18 | }); 19 | describe("randomBytes", () => { 20 | it("returns a URL safe string", () => { 21 | expect.assertions(1); 22 | 23 | expect(randomBytes(32)).to.match(URL_SAFE_REGEXP); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { base64 } from "@hexagon/base64"; 2 | import type { HonoRequest } from "hono"; 3 | import type z from "zod"; 4 | 5 | export async function requestBody( 6 | req: HonoRequest, 7 | schema: T, 8 | // biome-ignore lint/suspicious/noExplicitAny: Input type is `any` as it comes from the request 9 | ): Promise>> { 10 | const contentType = req.header("Content-Type")?.toLowerCase(); 11 | if ( 12 | contentType === "application/json" || 13 | contentType?.startsWith("application/json") 14 | ) { 15 | const json = await req.json(); 16 | return await schema.safeParseAsync(json); 17 | } 18 | 19 | const formData = await req.parseBody(); 20 | return await schema.safeParseAsync(formData); 21 | } 22 | 23 | // URL safe in ABNF is: ALPHA / DIGIT / "-" / "." / "_" / "~" 24 | export const URL_SAFE_REGEXP = /[A-Za-z0-9\_\-\.\~]/; 25 | 26 | export function base64Url(buffer: ArrayBuffer) { 27 | return base64.fromArrayBuffer(buffer, true); 28 | } 29 | 30 | export function randomBytes(length: number): string { 31 | return base64Url(crypto.getRandomValues(new Uint8Array(length)).buffer); 32 | } 33 | -------------------------------------------------------------------------------- /src/image.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | const app = new Hono(); 4 | 5 | app.get("/avatars/original/missing.png", (c) => { 6 | return c.html( 7 | 16 | Default avatar 17 | 24 | 31 | , 32 | 200, 33 | { "Content-Type": "image/svg+xml" }, 34 | ); 35 | }); 36 | 37 | app.get("/headers/original/missing.png", (c) => { 38 | const emptyPng = new Uint8Array([ 39 | 0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa, 0x0, 0x0, 0x0, 0xd, 0x49, 0x48, 40 | 0x44, 0x52, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x1, 0x8, 0x4, 0x0, 0x0, 0x0, 41 | 0xb5, 0x1c, 0xc, 0x2, 0x0, 0x0, 0x0, 0xb, 0x49, 0x44, 0x41, 0x54, 0x78, 0x1, 42 | 0x63, 0x60, 0x60, 0x0, 0x0, 0x0, 0x3, 0x0, 0x1, 0x8c, 0xf8, 0x39, 0x3a, 0x0, 43 | 0x0, 0x0, 0x0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 44 | ]); 45 | return c.body(emptyPng.buffer as ArrayBuffer); 46 | }); 47 | 48 | export default app; 49 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./logging"; 2 | import { join, relative } from "node:path"; 3 | import { federation } from "@fedify/fedify/x/hono"; 4 | import { serveStatic } from "@hono/node-server/serve-static"; 5 | import { captureException } from "@sentry/core"; 6 | import { Hono } from "hono"; 7 | import { cors } from "hono/cors"; 8 | 9 | import api from "./api"; 10 | import fedi from "./federation"; 11 | import image from "./image"; 12 | import oauth from "./oauth"; 13 | import oauthMetadataEndpoint from "./oauth/endpoints/metadata"; 14 | import pages from "./pages"; 15 | import { DRIVE_DISK, FS_STORAGE_PATH } from "./storage"; 16 | 17 | const app = new Hono(); 18 | 19 | app.onError((err, _) => { 20 | captureException(err); 21 | throw err; 22 | }); 23 | 24 | if (DRIVE_DISK === "fs") { 25 | app.use( 26 | "/assets/*", 27 | serveStatic({ 28 | root: relative(process.cwd(), FS_STORAGE_PATH!), 29 | rewriteRequestPath: (path) => path.substring("/assets".length), 30 | }), 31 | ); 32 | } 33 | 34 | app.use( 35 | "/public/*", 36 | serveStatic({ 37 | root: relative(process.cwd(), join(import.meta.dirname, "public")), 38 | rewriteRequestPath: (path) => path.substring("/public".length), 39 | }), 40 | ); 41 | 42 | const CorsPolicy = (allowMethods: string[]) => 43 | cors({ 44 | origin: "*", 45 | allowMethods: allowMethods, 46 | }); 47 | 48 | // Mastodon's CORS policy also allows `/@:username` and `/users/:username` 49 | // the /api router adds its own cors policy middleware: 50 | app.use("/.well-known/*", CorsPolicy(["GET"])); 51 | app.use("/nodeinfo/*", CorsPolicy(["GET"])); 52 | app.use("/oauth/token", CorsPolicy(["POST"])); 53 | app.use("/oauth/revoke", CorsPolicy(["POST"])); 54 | app.use("/oauth/userinfo", CorsPolicy(["GET", "POST"])); 55 | 56 | app.route("/.well-known/oauth-authorization-server", oauthMetadataEndpoint); 57 | 58 | app.use(federation(fedi, (_) => undefined)); 59 | 60 | app.route("/", pages); 61 | app.route("/oauth", oauth); 62 | app.route("/api", api); 63 | app.route("/image", image); 64 | 65 | app.get("/nodeinfo/2.0", (c) => c.redirect("/nodeinfo/2.1")); 66 | 67 | export default app; 68 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import { Writable } from "node:stream"; 3 | import { getFileSink } from "@logtape/file"; 4 | import { 5 | type LogLevel, 6 | configure, 7 | getAnsiColorFormatter, 8 | getStreamSink, 9 | parseLogLevel, 10 | } from "@logtape/logtape"; 11 | 12 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 13 | const LOG_LEVEL: LogLevel = parseLogLevel(process.env["LOG_LEVEL"] ?? "info"); 14 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 15 | const LOG_QUERY: boolean = process.env["LOG_QUERY"] === "true"; 16 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 17 | const LOG_FILE: string | undefined = process.env["LOG_FILE"]; 18 | 19 | await configure({ 20 | contextLocalStorage: new AsyncLocalStorage(), 21 | sinks: { 22 | console: getStreamSink(Writable.toWeb(process.stderr) as WritableStream, { 23 | formatter: getAnsiColorFormatter({ 24 | timestamp: "time", 25 | }), 26 | }), 27 | file: 28 | LOG_FILE == null 29 | ? () => undefined 30 | : getFileSink(LOG_FILE, { 31 | formatter: JSON.stringify.bind(JSON), 32 | }), 33 | }, 34 | filters: {}, 35 | loggers: [ 36 | { 37 | category: "fedify", 38 | lowestLevel: LOG_LEVEL, 39 | sinks: ["console", "file"], 40 | }, 41 | { 42 | category: "hollo", 43 | lowestLevel: LOG_LEVEL, 44 | sinks: ["console", "file"], 45 | }, 46 | { 47 | category: "drizzle-orm", 48 | lowestLevel: LOG_QUERY ? "debug" : "fatal", 49 | sinks: ["console", "file"], 50 | }, 51 | { 52 | category: ["logtape", "meta"], 53 | lowestLevel: "warning", 54 | sinks: ["console", "file"], 55 | }, 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /src/login.ts: -------------------------------------------------------------------------------- 1 | import { getSignedCookie } from "hono/cookie"; 2 | import { createMiddleware } from "hono/factory"; 3 | import { db } from "./db"; 4 | import { SECRET_KEY } from "./env"; 5 | 6 | export const loginRequired = createMiddleware(async (c, next) => { 7 | const login = await getSignedCookie(c, SECRET_KEY, "login"); 8 | if (login == null || login === false) { 9 | return c.redirect(`/login?next=${encodeURIComponent(c.req.url)}`); 10 | } 11 | const totp = await db.query.totps.findFirst(); 12 | if (totp != null) { 13 | const otp = await getSignedCookie(c, SECRET_KEY, "otp"); 14 | if (otp == null || otp === false || otp !== `${login} totp`) { 15 | return c.redirect(`/login/otp?next=${encodeURIComponent(c.req.url)}`); 16 | } 17 | } 18 | await next(); 19 | }); 20 | -------------------------------------------------------------------------------- /src/media.ts: -------------------------------------------------------------------------------- 1 | import { mkdtemp } from "node:fs/promises"; 2 | import { readFile, writeFile } from "node:fs/promises"; 3 | import { tmpdir } from "node:os"; 4 | import { join } from "node:path"; 5 | import ffmpeg from "fluent-ffmpeg"; 6 | import type { Sharp } from "sharp"; 7 | import { drive } from "./storage"; 8 | 9 | const DEFAULT_THUMBNAIL_AREA = 230_400; 10 | 11 | export interface Thumbnail { 12 | thumbnailUrl: string; 13 | thumbnailType: string; 14 | thumbnailWidth: number; 15 | thumbnailHeight: number; 16 | } 17 | 18 | export async function uploadThumbnail( 19 | id: string, 20 | original: Sharp, 21 | thumbnailArea = DEFAULT_THUMBNAIL_AREA, 22 | ): Promise { 23 | const disk = drive.use(); 24 | const originalMetadata = await original.metadata(); 25 | let width = originalMetadata.width!; 26 | let height = originalMetadata.height!; 27 | if ( 28 | originalMetadata.orientation != null && 29 | originalMetadata.orientation !== 1 30 | ) { 31 | // biome-ignore lint/style/noParameterAssign: 32 | original = original.clone(); 33 | original.rotate(); 34 | if (originalMetadata.orientation !== 3) { 35 | [width, height] = [height, width]; 36 | } 37 | } 38 | const thumbnailSize = calculateThumbnailSize(width, height, thumbnailArea); 39 | const thumbnail = await original 40 | .resize(thumbnailSize) 41 | .webp({ nearLossless: true }) 42 | .toBuffer(); 43 | const content = new Uint8Array(thumbnail); 44 | try { 45 | await disk.put(`media/${id}/thumbnail.webp`, content, { 46 | contentType: "image/webp", 47 | contentLength: content.byteLength, 48 | visibility: "public", 49 | }); 50 | } catch (error: unknown) { 51 | if (error instanceof Error) { 52 | throw new Error(`Failed to store thumbnail: ${error.message}`, error); 53 | } 54 | throw error; 55 | } 56 | return { 57 | thumbnailUrl: await disk.getUrl(`media/${id}/thumbnail.webp`), 58 | thumbnailType: "image/webp", 59 | thumbnailWidth: thumbnailSize.width, 60 | thumbnailHeight: thumbnailSize.height, 61 | }; 62 | } 63 | 64 | export function calculateThumbnailSize( 65 | width: number, 66 | height: number, 67 | maxArea: number, 68 | ): { width: number; height: number } { 69 | const ratio = width / height; 70 | if (width * height <= maxArea) return { width, height }; 71 | const newHeight = Math.sqrt(maxArea / ratio); 72 | const newWidth = ratio * newHeight; 73 | return { width: Math.round(newWidth), height: Math.round(newHeight) }; 74 | } 75 | 76 | export async function makeVideoScreenshot( 77 | videoData: Uint8Array, 78 | ): Promise { 79 | const tmpDir = await mkdtemp(join(tmpdir(), "hollo-")); 80 | const inFile = join(tmpDir, "video"); 81 | await writeFile(inFile, videoData); 82 | await new Promise((resolve) => 83 | ffmpeg(inFile) 84 | .on("end", resolve) 85 | .screenshots({ 86 | timestamps: [0], 87 | filename: "screenshot.png", 88 | folder: tmpDir, 89 | }), 90 | ); 91 | const screenshot = await readFile(join(tmpDir, "screenshot.png")); 92 | return new Uint8Array(screenshot.buffer); 93 | } 94 | -------------------------------------------------------------------------------- /src/oauth/constants.ts: -------------------------------------------------------------------------------- 1 | export const OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"; 2 | export const ACCESS_GRANT_EXPIRES_IN = 10 * 60 * 1000; 3 | export const ACCESS_GRANT_DELETE_AFTER = 3600 * 24 * 1000; 4 | 5 | export const ACCESS_GRANT_SIZE = 64; 6 | export const ACCESS_TOKEN_SIZE = 64; 7 | -------------------------------------------------------------------------------- /src/oauth/endpoints/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { Hono } from "hono"; 4 | 5 | import * as Schema from "../../schema"; 6 | import metadataEndpoint from "./metadata"; 7 | 8 | describe("GET /.well-known/oauth-authorization-server", () => { 9 | const app = new Hono(); 10 | app.route("/.well-known/oauth-authorization-server", metadataEndpoint); 11 | 12 | it("returns OAuth authorization server metadata", async () => { 13 | expect.assertions(15); 14 | 15 | // We use the full URL in this test as the route calculates values based 16 | // on the Host header 17 | const response = await app.request( 18 | "https://hollo.test/.well-known/oauth-authorization-server", 19 | { 20 | method: "GET", 21 | }, 22 | ); 23 | 24 | expect(response.status).toBe(200); 25 | 26 | const json = await response.json(); 27 | 28 | expect(json.issuer).toBe("https://hollo.test/"); 29 | expect(json.authorization_endpoint).toBe( 30 | "https://hollo.test/oauth/authorize", 31 | ); 32 | expect(json.token_endpoint).toBe("https://hollo.test/oauth/token"); 33 | expect(json.revocation_endpoint).toBe("https://hollo.test/oauth/revoke"); 34 | expect(json.userinfo_endpoint).toBe("https://hollo.test/oauth/userinfo"); 35 | // Non-standard, mastodon extension: 36 | expect(json.app_registration_endpoint).toBe( 37 | "https://hollo.test/api/v1/apps", 38 | ); 39 | 40 | expect(json.response_types_supported).toEqual(["code"]); 41 | expect(json.response_modes_supported).toEqual(["query"]); 42 | expect(json.grant_types_supported).toEqual([ 43 | "authorization_code", 44 | "client_credentials", 45 | ]); 46 | expect(json.token_endpoint_auth_methods_supported).toEqual([ 47 | "client_secret_post", 48 | "client_secret_basic", 49 | ]); 50 | 51 | expect(Array.isArray(json.scopes_supported)).toBeTruthy(); 52 | expect(json.scopes_supported).toEqual(Schema.scopeEnum.enumValues); 53 | 54 | expect(Array.isArray(json.code_challenge_methods_supported)).toBeTruthy(); 55 | expect(json.code_challenge_methods_supported).toEqual(["S256"]); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/oauth/endpoints/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import * as Schema from "../../schema"; 3 | 4 | const app = new Hono(); 5 | 6 | app.get("/", (c) => { 7 | const url = new URL(c.req.url); 8 | 9 | return c.json({ 10 | issuer: new URL("/", url).href, 11 | authorization_endpoint: new URL("/oauth/authorize", url).href, 12 | token_endpoint: new URL("/oauth/token", url).href, 13 | revocation_endpoint: new URL("/oauth/revoke", url).href, 14 | userinfo_endpoint: new URL("/oauth/userinfo", url).href, 15 | scopes_supported: Schema.scopeEnum.enumValues, 16 | response_types_supported: ["code"], 17 | response_modes_supported: ["query"], 18 | grant_types_supported: ["authorization_code", "client_credentials"], 19 | token_endpoint_auth_methods_supported: [ 20 | "client_secret_post", 21 | "client_secret_basic", 22 | // Not supported until we support public clients: 23 | // "none", 24 | ], 25 | code_challenge_methods_supported: ["S256"], 26 | app_registration_endpoint: new URL("/api/v1/apps", url).href, 27 | }); 28 | }); 29 | 30 | export default app; 31 | -------------------------------------------------------------------------------- /src/oauth/endpoints/revoke.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { Hono } from "hono"; 3 | import { z } from "zod"; 4 | import db from "../../db"; 5 | import { requestBody } from "../../helpers"; 6 | import * as Schema from "../../schema"; 7 | import { 8 | type ClientAuthenticationVariables, 9 | clientAuthentication, 10 | } from "../middleware"; 11 | 12 | const app = new Hono<{ Variables: ClientAuthenticationVariables }>(); 13 | 14 | // RFC7009 - OAuth Token Revocation: 15 | const tokenRevocationSchema = z.strictObject({ 16 | token: z.string(), 17 | token_type_hint: z.string().optional(), 18 | // client_id and client_secret are present but consumed by the 19 | // clientAuthentication middleware: 20 | client_id: z.string().optional(), 21 | client_secret: z.string().optional(), 22 | }); 23 | 24 | app.post("/", clientAuthentication, async (c) => { 25 | const client = c.get("client"); 26 | const result = await requestBody(c.req, tokenRevocationSchema); 27 | 28 | if (!result.success) { 29 | return c.json({ error: "invalid_request", zod_error: result.error }, 400); 30 | } 31 | 32 | if ( 33 | result.data.token_type_hint && 34 | result.data.token_type_hint !== "access_token" 35 | ) { 36 | return c.json( 37 | { 38 | error: "unsupported_token_type", 39 | error_description: 40 | "The authorization server does not support the revocation of the presented token type", 41 | }, 42 | 400, 43 | ); 44 | } 45 | 46 | await db 47 | .delete(Schema.accessTokens) 48 | .where( 49 | and( 50 | eq(Schema.accessTokens.code, result.data.token), 51 | eq(Schema.accessTokens.applicationId, client.id), 52 | ), 53 | ); 54 | 55 | // The spec is a little strange here in that the response status is 200, but 56 | // there's actually no response body, so 204 would be more appropriate. 57 | // We return an empty json response to make testing easier: 58 | return c.json({}, 200); 59 | }); 60 | 61 | export default app; 62 | -------------------------------------------------------------------------------- /src/oauth/endpoints/userinfo.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { type Variables, scopeRequired, tokenRequired } from "../middleware"; 3 | 4 | const app = new Hono<{ Variables: Variables }>(); 5 | 6 | app.on(["GET", "POST"], "/", tokenRequired, scopeRequired(["profile"]), (c) => { 7 | const accountOwner = c.get("token").accountOwner; 8 | 9 | if (!accountOwner) { 10 | return c.json( 11 | { 12 | error: "This method requires an authenticated user", 13 | }, 14 | 401, 15 | ); 16 | } 17 | 18 | const defaultAvatarUrl = new URL( 19 | "/image/avatars/original/missing.png", 20 | c.req.url, 21 | ).href; 22 | 23 | return c.json({ 24 | iss: new URL("/", c.req.url).href, 25 | sub: accountOwner.account.iri, 26 | name: accountOwner.account.name, 27 | preferredUsername: accountOwner.handle, 28 | profile: accountOwner.account.url, 29 | picture: accountOwner.account.avatarUrl ?? defaultAvatarUrl, 30 | }); 31 | }); 32 | 33 | export default app; 34 | -------------------------------------------------------------------------------- /src/oauth/validators.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { scopesSchema } from "./validators"; 4 | 5 | describe("OAuth / Validators", () => { 6 | it("can parse a single scope", async () => { 7 | expect.assertions(4); 8 | 9 | const result = await scopesSchema.safeParseAsync("read"); 10 | 11 | expect(result.success).toBe(true); 12 | expect(result.error).toBeUndefined(); 13 | expect(Array.isArray(result.data)).toBeTruthy(); 14 | expect(result.data).toEqual(["read"]); 15 | }); 16 | 17 | it("can parse multiple scopes", async () => { 18 | expect.assertions(4); 19 | 20 | const result = await scopesSchema.safeParseAsync("read write"); 21 | 22 | expect(result.success).toBe(true); 23 | expect(result.error).toBeUndefined(); 24 | expect(Array.isArray(result.data)).toBeTruthy(); 25 | expect(result.data).toEqual(["read", "write"]); 26 | }); 27 | 28 | it("returns an error if the scope is invalid", async () => { 29 | expect.assertions(4); 30 | 31 | const result = await scopesSchema.safeParseAsync("invalid"); 32 | 33 | expect(result.success).toBe(false); 34 | expect(result.error).not.toBeNull(); 35 | expect(result.error?.errors[0].code).toBe("invalid_enum_value"); 36 | expect(Array.isArray(result.data)).toBeFalsy(); 37 | }); 38 | 39 | it("returns an error if one of the scopes is invalid", async () => { 40 | expect.assertions(4); 41 | 42 | const result = await scopesSchema.safeParseAsync("read invalid write"); 43 | 44 | expect(result.success).toBe(false); 45 | expect(result.error).not.toBeNull(); 46 | expect(result.error?.errors[0].code).toBe("invalid_enum_value"); 47 | expect(Array.isArray(result.data)).toBeFalsy(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/oauth/validators.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { type Scope, scopeEnum } from "../schema"; 3 | 4 | export const scopesSchema = z 5 | .string() 6 | .trim() 7 | .transform((v, ctx) => { 8 | const scopes: Scope[] = []; 9 | for (const scope of v.split(/\s+/g)) { 10 | if (!scopeEnum.enumValues.includes(scope as Scope)) { 11 | ctx.addIssue({ 12 | code: z.ZodIssueCode.invalid_enum_value, 13 | options: scopeEnum.enumValues, 14 | received: scope, 15 | }); 16 | return z.NEVER; 17 | } 18 | scopes.push(scope as Scope); 19 | } 20 | return scopes; 21 | }); 22 | -------------------------------------------------------------------------------- /src/pages/emojis.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { getFixtureFile } from "../../tests/helpers"; 4 | import { getLoginCookie } from "../../tests/helpers/web"; 5 | 6 | import db from "../db"; 7 | import { customEmojis } from "../schema"; 8 | 9 | import { drive } from "../storage"; 10 | import app from "./index"; 11 | 12 | const emojiFile = await getFixtureFile("emoji.png", "image/png"); 13 | 14 | describe.sequential("emojis", () => { 15 | beforeEach(async () => { 16 | await db.delete(customEmojis); 17 | 18 | return () => { 19 | drive.restore(); 20 | }; 21 | }); 22 | 23 | it("Successfully saves a new emoji", async () => { 24 | expect.assertions(4); 25 | 26 | const disk = drive.fake(); 27 | const testShortCode = ":test-emoji:"; 28 | 29 | const formData = new FormData(); 30 | formData.append("shortcode", testShortCode); 31 | formData.append("image", emojiFile); 32 | 33 | const cookie = await getLoginCookie(); 34 | 35 | const response = await app.request("/emojis", { 36 | method: "POST", 37 | body: formData, 38 | headers: { 39 | Cookie: cookie, 40 | }, 41 | }); 42 | 43 | expect(response.status).toBe(302); 44 | expect(response.headers.get("Location")).toBe("/emojis"); 45 | 46 | // Assert we uploaded the file: 47 | expect(() => disk.assertExists("emojis/test-emoji.png")).not.toThrowError(); 48 | 49 | const emoji = await db.query.customEmojis.findFirst(); 50 | 51 | expect(emoji).toMatchObject({ 52 | category: null, 53 | url: "http://hollo.test/assets/emojis/test-emoji.png", 54 | shortcode: "test-emoji", 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { escape } from "es-toolkit"; 2 | import { Hono } from "hono"; 3 | import { Layout } from "../../components/Layout.tsx"; 4 | import db from "../../db.ts"; 5 | import { renderCustomEmojis } from "../../text.ts"; 6 | 7 | const homePage = new Hono().basePath("/"); 8 | 9 | homePage.get("/", async (c) => { 10 | const credential = await db.query.credentials.findFirst(); 11 | if (credential == null) return c.redirect("/setup"); 12 | const owners = await db.query.accountOwners.findMany({ 13 | with: { account: true }, 14 | }); 15 | if (owners.length < 1) return c.redirect("/accounts"); 16 | if ( 17 | "HOME_URL" in process.env && 18 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 19 | process.env["HOME_URL"] != null && 20 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 21 | process.env["HOME_URL"].trim() !== "" 22 | ) { 23 | // biome-ignore lint/complexity/useLiteralKeys: tsc complains about this (TS4111) 24 | return c.redirect(process.env["HOME_URL"]); 25 | } 26 | const host = new URL(c.req.url).host; 27 | return c.html( 28 | 29 |
30 |

{host}

31 |

This Hollo instance has the below accounts.

32 |
33 | {owners.map((owner) => { 34 | const url = owner.account.url ?? owner.account.iri; 35 | const nameHtml = renderCustomEmojis( 36 | escape(owner.account.name), 37 | owner.account.emojis, 38 | ); 39 | const bioHtml = renderCustomEmojis( 40 | owner.account.bioHtml ?? "", 41 | owner.account.emojis, 42 | ); 43 | return ( 44 |
68 | ); 69 | })} 70 | 75 | , 76 | ); 77 | }); 78 | 79 | export default homePage; 80 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { trimTrailingSlash } from "hono/trailing-slash"; 3 | import accounts from "./accounts"; 4 | import auth from "./auth"; 5 | import emojis from "./emojis"; 6 | import federation from "./federation"; 7 | import home from "./home"; 8 | import login from "./login"; 9 | import logout from "./logout"; 10 | import profile from "./profile"; 11 | import setup from "./setup"; 12 | import tags from "./tags"; 13 | 14 | const page = new Hono(); 15 | 16 | page.use(trimTrailingSlash()); 17 | page.route("/", home); 18 | page.route("/:handle{@[^/]+}", profile); 19 | page.route("/login", login); 20 | page.route("/logout", logout); 21 | page.route("/setup", setup); 22 | page.route("/auth", auth); 23 | page.route("/accounts", accounts); 24 | page.route("/emojis", emojis); 25 | page.route("/federation", federation); 26 | page.route("/tags", tags); 27 | 28 | export default page; 29 | -------------------------------------------------------------------------------- /src/pages/logout.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { deleteCookie } from "hono/cookie"; 3 | 4 | const logout = new Hono(); 5 | 6 | logout.post("/", async (c) => { 7 | await deleteCookie(c, "login"); 8 | return c.redirect("/"); 9 | }); 10 | 11 | export default logout; 12 | -------------------------------------------------------------------------------- /src/pages/oauth/authorization_code.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "../../components/Layout"; 2 | import type { Application } from "../../schema"; 3 | 4 | interface AuthorizationCodePageProps { 5 | application: Application; 6 | code: string; 7 | } 8 | 9 | export function AuthorizationCodePage(props: AuthorizationCodePageProps) { 10 | return ( 11 | 12 |
13 |

Authorization Code

14 |

Here is your authorization code.

15 |
16 |
{props.code}
17 |

18 | Copy this code and paste it into {props.application.name}. 19 |

20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/previewcard.ts: -------------------------------------------------------------------------------- 1 | import ogs from "open-graph-scraper"; 2 | 3 | export interface PreviewCard { 4 | url: string; 5 | title: string; 6 | description: string | null; 7 | image: { 8 | url: string; 9 | type: string | null; 10 | width: number | null; 11 | height: number | null; 12 | } | null; 13 | } 14 | 15 | export async function fetchPreviewCard( 16 | url: string | URL, 17 | ): Promise { 18 | let response: Awaited>; 19 | try { 20 | response = await ogs({ url: url.toString() }); 21 | } catch (_) { 22 | return null; 23 | } 24 | const { error, result } = response; 25 | if (error || !result.success || result.ogTitle == null) return null; 26 | return { 27 | url: result.ogUrl ?? url.toString(), 28 | title: result.ogTitle, 29 | description: result.ogDescription ?? "", 30 | image: 31 | result.ogImage == null || result.ogImage.length < 1 32 | ? null 33 | : { 34 | url: result.ogImage[0].url, 35 | type: result.ogImage[0].type ?? null, 36 | width: 37 | result.ogImage[0].width == null 38 | ? null 39 | : Number.parseInt(result.ogImage[0].width as unknown as string), 40 | height: 41 | result.ogImage[0].height == null 42 | ? null 43 | : Number.parseInt( 44 | result.ogImage[0].height as unknown as string, 45 | ), 46 | }, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/public/favicon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/src/public/favicon-white.png -------------------------------------------------------------------------------- /src/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/src/public/favicon.png -------------------------------------------------------------------------------- /src/public/hollo.css: -------------------------------------------------------------------------------- 1 | .logout-form { 2 | display: inline-flex; 3 | margin: 0; 4 | } 5 | 6 | .logout-btn { 7 | margin: 0; 8 | padding: var(--pico-nav-link-spacing-vertical) 9 | var(--pico-nav-link-spacing-horizontal); 10 | display: inline-block; 11 | width: auto !important; 12 | } 13 | 14 | footer { 15 | margin-block: calc(var(--pico-spacing) * 2); 16 | padding-top: calc(var(--pico-spacing) * 2); 17 | border-top: var(--pico-border-width) solid var(--pico-muted-border-color); 18 | font-size: 0.75rem; 19 | text-align: center; 20 | } 21 | 22 | @media (prefers-color-scheme: dark) { 23 | .shiki, 24 | .shiki span { 25 | color: var(--shiki-dark) !important; 26 | background-color: var(--shiki-dark-bg) !important; 27 | font-style: var(--shiki-dark-font-style) !important; 28 | font-weight: var(--shiki-dark-font-weight) !important; 29 | text-decoration: var(--shiki-dark-text-decoration) !important; 30 | } 31 | } 32 | 33 | /* cSpell: ignore shiki */ 34 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@logtape/logtape"; 2 | import { getGlobalScope, setCurrentClient } from "@sentry/core"; 3 | import { type NodeClient, init, initOpenTelemetry } from "@sentry/node"; 4 | 5 | const logger = getLogger(["hollo", "sentry"]); 6 | 7 | export function configureSentry(dsn?: string): NodeClient | undefined { 8 | if (dsn == null || dsn.trim() === "") { 9 | logger.debug("SENTRY_DSN is not provided. Sentry will not be initialized."); 10 | return; 11 | } 12 | 13 | const client = init({ 14 | dsn, 15 | tracesSampleRate: 1.0, 16 | }); 17 | if (client == null) { 18 | logger.error("Failed to initialize Sentry."); 19 | return; 20 | } 21 | getGlobalScope().setClient(client); 22 | setCurrentClient(client); 23 | logger.debug("Sentry initialized."); 24 | 25 | initOpenTelemetry(client); 26 | return client; 27 | } 28 | -------------------------------------------------------------------------------- /src/uuid.ts: -------------------------------------------------------------------------------- 1 | import { uuidv7 as generateUuidV7 } from "uuidv7-js"; 2 | import { z } from "zod"; 3 | 4 | export type Uuid = ReturnType; 5 | 6 | export function uuidv7(timestamp?: number): Uuid { 7 | return generateUuidV7(timestamp) as Uuid; 8 | } 9 | 10 | const UUID_REGEXP = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i; 11 | 12 | export function isUuid(value: string): value is Uuid { 13 | return UUID_REGEXP.exec(value) != null; 14 | } 15 | 16 | export const uuid = z.custom( 17 | (v: unknown) => typeof v === "string" && isUuid(v), 18 | "expected a UUID", 19 | ); 20 | -------------------------------------------------------------------------------- /tests/fixtures/files/emoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedify-dev/hollo/a623cb7fec0d2319913936ca3b62a9166dc01e32/tests/fixtures/files/emoji.png -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { after, before } from "node:test"; 4 | import { sql } from "drizzle-orm"; 5 | 6 | import db from "../src/db"; 7 | import { drive } from "../src/storage"; 8 | 9 | const fixtureFiles = join(import.meta.dirname, "fixtures", "files"); 10 | 11 | export async function getFixtureFile( 12 | name: string, 13 | type: string, 14 | ): Promise { 15 | const filePath = join(fixtureFiles, name); 16 | const data = await readFile(filePath); 17 | 18 | return new File([data], name, { 19 | type, 20 | }); 21 | } 22 | 23 | export async function cleanDatabase() { 24 | const schema = "public"; 25 | const tables = await db.execute>( 26 | sql`SELECT table_name FROM information_schema.tables WHERE table_schema = ${schema} AND table_type = 'BASE TABLE';`, 27 | ); 28 | 29 | const tableExpression = tables 30 | .map((table) => { 31 | return [`"${schema}"`, `"${table.table_name}"`].join("."); 32 | }) 33 | .join(", "); 34 | 35 | await db.execute( 36 | sql.raw(`TRUNCATE TABLE ${tableExpression} RESTART IDENTITY CASCADE`), 37 | ); 38 | } 39 | 40 | before(async () => { 41 | await cleanDatabase(); 42 | }); 43 | 44 | // Automatically close the database and remove test file uploads 45 | // Without this the tests hang due to the database 46 | after(async () => { 47 | await db.$client.end({ timeout: 5 }); 48 | 49 | const disk = drive.fake(); 50 | await disk.deleteAll(); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/helpers/web.ts: -------------------------------------------------------------------------------- 1 | import { serializeSigned } from "hono/utils/cookie"; 2 | import { SECRET_KEY } from "../../src/env"; 3 | 4 | export async function getLoginCookie() { 5 | // Same logic as in src/pages/login.tsx 6 | return serializeSigned("login", new Date().toISOString(), SECRET_KEY!, { 7 | path: "/", 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM"], 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "jsx": "react-jsx", 8 | "jsxImportSource": "hono/jsx", 9 | "allowJs": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noPropertyAccessFromIndexSignature": false 21 | }, 22 | "exclude": ["node_modules", "docs"] 23 | } 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { parse } from "dotenv"; 4 | import { defineConfig } from "vitest/config"; 5 | 6 | const env = parse( 7 | await readFile(join(process.cwd(), ".env.test"), { encoding: "utf-8" }), 8 | ); 9 | 10 | export default defineConfig(() => ({ 11 | test: { 12 | env: env, 13 | reporters: process.env.GITHUB_ACTIONS 14 | ? ["default", "github-actions"] 15 | : ["default"], 16 | fileParallelism: false, 17 | expect: { 18 | requireAssertions: true, 19 | }, 20 | coverage: { 21 | include: ["src/**/*.ts", "src/**/*.tsx"], 22 | // These files don't really make sense to try to collect coverage on as 23 | // they're setup files: 24 | exclude: [ 25 | "src/env.ts", 26 | "src/logging.ts", 27 | "src/sentry.ts", 28 | // database setup: 29 | "src/db.ts", 30 | "src/schema.ts", 31 | // storage setup: 32 | "src/storage.ts", 33 | ], 34 | }, 35 | }, 36 | })); 37 | --------------------------------------------------------------------------------