From bc9f96cbd4b27d101f65908b9b90b8c22b665b56 Mon Sep 17 00:00:00 2001 From: Malin Date: Tue, 24 Feb 2026 09:30:19 +0100 Subject: [PATCH] feat: rebrand Hemmelig to paste.es for cloudhost.es - Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 56 + .editorconfig | 9 + .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug.yml | 25 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/docs.yml | 26 + .github/ISSUE_TEMPLATE/feature_request.yml | 27 + .github/ISSUE_TEMPLATE/other.yml | 22 + .github/workflows/cli-release.yml | 168 + .github/workflows/publish_docker_image.yaml | 59 + .github/workflows/release.yml | 108 + .github/workflows/trivy.yaml | 51 + .gitignore | 139 + .husky/.gitignore | 1 + .husky/pre-commit | 1 + .npmignore | 9 + .prettierignore | 11 + .prettierrc | 10 + CLAUDE.md | 640 ++ Dockerfile | 72 + LICENSE | 8 + README.md | 135 + api/app.ts | 158 + api/auth.ts | 176 + api/config.ts | 255 + api/jobs/expired.ts | 67 + api/jobs/index.ts | 14 + api/lib/analytics.ts | 62 + api/lib/constants.ts | 85 + api/lib/db.ts | 19 + api/lib/files.ts | 77 + api/lib/password.ts | 49 + api/lib/settings.ts | 40 + api/lib/utils.ts | 130 + api/lib/webhook.ts | 106 + api/middlewares/auth.ts | 91 + api/middlewares/ip-restriction.ts | 33 + api/middlewares/ratelimit.ts | 32 + api/openapi.ts | 1568 +++ api/routes.ts | 48 + api/routes/account.ts | 130 + api/routes/analytics.ts | 253 + api/routes/api-keys.ts | 156 + api/routes/files.ts | 134 + api/routes/health.ts | 131 + api/routes/instance.ts | 169 + api/routes/invites.ts | 151 + api/routes/metrics.ts | 159 + api/routes/secret-requests.ts | 455 + api/routes/secrets.ts | 343 + api/routes/setup.ts | 82 + api/routes/user.ts | 89 + api/validations/account.ts | 34 + api/validations/instance.ts | 60 + api/validations/password.ts | 45 + api/validations/secret-requests.ts | 122 + api/validations/secrets.ts | 120 + api/validations/user.ts | 22 + banner.png | Bin 0 -> 10525 bytes cli-go/.gitignore | 7 + cli-go/README.md | 126 + cli-go/go.mod | 5 + cli-go/go.sum | 2 + cli-go/main.go | 343 + cli/README.md | 146 + cli/package-lock.json | 54 + cli/package.json | 54 + cli/src/bin.ts | 197 + cli/src/index.ts | 210 + cli/tsconfig.json | 19 + docker-compose.yml | 30 + docs/api.md | 106 + docs/cli.md | 374 + docs/docker.md | 267 + docs/e2e.md | 178 + docs/encryption.md | 123 + docs/env.md | 186 + docs/health.md | 95 + docs/helm-oauth.md | 141 + docs/helm.md | 205 + docs/managed.md | 299 + docs/metrics.md | 85 + docs/sdk.md | 77 + docs/secret-request.md | 147 + docs/social-login.md | 451 + docs/upgrade.md | 33 + docs/webhook.md | 230 + eslint.config.js | 25 + helm/hemmelig/.helmignore | 18 + helm/hemmelig/Chart.yaml | 17 + helm/hemmelig/templates/NOTES.txt | 38 + helm/hemmelig/templates/_helpers.tpl | 60 + helm/hemmelig/templates/deployment.yaml | 318 + helm/hemmelig/templates/ingress.yaml | 41 + helm/hemmelig/templates/pvc.yaml | 35 + helm/hemmelig/templates/secret.yaml | 78 + helm/hemmelig/templates/service.yaml | 15 + helm/hemmelig/templates/serviceaccount.yaml | 13 + helm/hemmelig/values.yaml | 153 + index.html | 51 + logo.png | Bin 0 -> 95240 bytes logo.svg | 25 + logo_color.png | Bin 0 -> 27468 bytes mise.toml | 4 + package-lock.json | 9673 +++++++++++++++++ package.json | 122 + playwright.config.ts | 32 + prisma.config.ts | 12 + .../migration.sql | 71 + .../migration.sql | 26 + .../20250626183203_add_username/migration.sql | 25 + .../20250626192902_username/migration.sql | 2 + .../20250629090604_to_number/migration.sql | 26 + .../migration.sql | 27 + .../migration.sql | 25 + .../migration.sql | 21 + .../migration.sql | 31 + .../migration.sql | 41 + .../migration.sql | 5 + .../migration.sql | 28 + .../migration.sql | 12 + .../migration.sql | 10 + .../20250720173355_salt/migration.sql | 28 + .../20251202061039_latest/migration.sql | 40 + .../migration.sql | 36 + .../migration.sql | 7 + .../migration.sql | 24 + .../migration.sql | 54 + .../migration.sql | 3 + .../20251203150440_new/migration.sql | 34 + .../migration.sql | 8 + .../migration.sql | 6 + .../20251204171251_add_api_keys/migration.sql | 15 + .../migration.sql | 4 + .../migration.sql | 43 + .../migration.sql | 2 + .../20251212205031_new/migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 37 + .../migration.sql | 11 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 237 + public/favicon.ico | Bin 0 -> 15086 bytes public/icons/android-chrome-192x192.png | Bin 0 -> 10617 bytes public/icons/android-chrome-512x512.png | Bin 0 -> 31914 bytes public/icons/apple-touch-icon.png | Bin 0 -> 9935 bytes public/icons/favicon-16x16.png | Bin 0 -> 1102 bytes public/icons/favicon-32x32.png | Bin 0 -> 1921 bytes public/icons/icon-128x128.png | Bin 0 -> 11407 bytes public/icons/icon-144x144.png | Bin 0 -> 12439 bytes public/icons/icon-152x152.png | Bin 0 -> 13103 bytes public/icons/icon-192x192.png | Bin 0 -> 17162 bytes public/icons/icon-384x384.png | Bin 0 -> 40171 bytes public/icons/icon-512x512.png | Bin 0 -> 22498 bytes public/icons/icon-72x72.png | Bin 0 -> 5419 bytes public/icons/icon-96x96.png | Bin 0 -> 7787 bytes public/icons/maskable-icon-192x192.png | Bin 0 -> 15291 bytes public/manifest.json | 58 + public/robots.txt | 3 + scripts/admin.ts | 35 + scripts/docker-entrypoint.sh | 8 + scripts/seed-demo.ts | 205 + scripts/update-packages.sh | 59 + server.ts | 55 + src/App.tsx | 28 + src/components/AddUserModal.tsx | 123 + src/components/Card.tsx | 43 + src/components/CreateButton.tsx | 42 + src/components/EditUserModal.tsx | 124 + src/components/Editor.tsx | 1006 ++ src/components/ErrorBoundary.tsx | 66 + src/components/ErrorDisplay.tsx | 44 + src/components/ExpirationSelect.tsx | 76 + src/components/FileUpload.tsx | 154 + src/components/Footer.tsx | 67 + src/components/Header.tsx | 99 + src/components/ImportantAlert.tsx | 50 + src/components/LanguagePicker.tsx | 41 + src/components/Layout/DashboardLayout.tsx | 240 + src/components/Layout/RootLayout.tsx | 15 + src/components/Logo.tsx | 28 + src/components/Modal.tsx | 61 + src/components/Pagination.tsx | 85 + src/components/SecretForm.tsx | 171 + src/components/SecretSettings.tsx | 168 + src/components/SecuritySettings.tsx | 241 + src/components/SocialLoginButtons.tsx | 266 + src/components/Sparkline.tsx | 53 + src/components/ThemeToggle.tsx | 22 + src/components/TitleField.tsx | 37 + src/components/ToggleSwitch.tsx | 27 + src/components/ToggleSwitchRow.tsx | 38 + src/components/ViewsSlider.tsx | 39 + src/hooks/useCopyFeedback.ts | 44 + src/hooks/useModalState.ts | 27 + src/i18n/i18n.ts | 44 + src/i18n/locales/da/da.json | 966 ++ src/i18n/locales/de/de.json | 966 ++ src/i18n/locales/en/en.json | 966 ++ src/i18n/locales/es/es.json | 966 ++ src/i18n/locales/fr/fr.json | 966 ++ src/i18n/locales/it/it.json | 966 ++ src/i18n/locales/nl/nl.json | 966 ++ src/i18n/locales/no/no.json | 966 ++ src/i18n/locales/sv/sv.json | 966 ++ src/i18n/locales/zh/zh.json | 966 ++ src/index.css | 90 + src/lib/analytics.ts | 26 + src/lib/api.ts | 50 + src/lib/auth.ts | 14 + src/lib/crypto.ts | 181 + src/lib/hash.ts | 9 + src/logo.svg | 25 + src/main.tsx | 17 + src/pages/Dashboard/Account/DangerZoneTab.tsx | 88 + src/pages/Dashboard/Account/DeveloperTab.tsx | 274 + src/pages/Dashboard/Account/ProfileTab.tsx | 107 + src/pages/Dashboard/Account/SecurityTab.tsx | 595 + src/pages/Dashboard/Account/index.ts | 4 + src/pages/Dashboard/AccountPage.tsx | 88 + src/pages/Dashboard/AnalyticsPage.tsx | 785 ++ .../Dashboard/CreateSecretRequestPage.tsx | 394 + src/pages/Dashboard/InstancePage.tsx | 1011 ++ src/pages/Dashboard/InvitesPage.tsx | 270 + src/pages/Dashboard/SecretRequestsPage.tsx | 317 + src/pages/Dashboard/SecretsPage.tsx | 297 + src/pages/Dashboard/UsersPage.tsx | 305 + src/pages/HomePage.tsx | 29 + src/pages/LoginPage.tsx | 195 + src/pages/NotFoundPage.tsx | 45 + src/pages/PrivacyPage.tsx | 160 + src/pages/RegisterPage.tsx | 468 + src/pages/RequestSecretPage.tsx | 285 + src/pages/SecretNotFoundPage.tsx | 41 + src/pages/SecretPage.tsx | 392 + src/pages/SetupPage.tsx | 198 + src/pages/TermsPage.tsx | 198 + src/pages/Verify2FAPage.tsx | 165 + src/router.tsx | 360 + src/store/accountStore.ts | 16 + src/store/errorStore.ts | 13 + src/store/hemmeligStore.ts | 299 + src/store/secretRequestStore.ts | 50 + src/store/secretSettingsStore.ts | 60 + src/store/secretStore.ts | 100 + src/store/themeStore.ts | 40 + src/store/userStore.ts | 49 + src/store/usersStore.ts | 80 + src/utils/clipboard.ts | 33 + src/utils/clsx.ts | 3 + src/utils/date.ts | 22 + src/utils/password.ts | 22 + src/vite-env.d.ts | 1 + tailwind.config.js | 41 + tests/e2e/auth.spec.ts | 83 + tests/e2e/fixtures.ts | 36 + tests/e2e/global-setup.ts | 71 + tests/e2e/global-teardown.ts | 19 + tests/e2e/home.spec.ts | 37 + tests/e2e/navigation.spec.ts | 33 + tests/e2e/password-change.spec.ts | 124 + tests/e2e/secret.spec.ts | 189 + tsconfig.app.json | 29 + tsconfig.json | 11 + tsconfig.node.json | 30 + vite.config.ts | 32 + 268 files changed, 45773 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/other.yml create mode 100644 .github/workflows/cli-release.yml create mode 100644 .github/workflows/publish_docker_image.yaml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/trivy.yaml create mode 100644 .gitignore create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100644 .npmignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/app.ts create mode 100644 api/auth.ts create mode 100644 api/config.ts create mode 100644 api/jobs/expired.ts create mode 100644 api/jobs/index.ts create mode 100644 api/lib/analytics.ts create mode 100644 api/lib/constants.ts create mode 100644 api/lib/db.ts create mode 100644 api/lib/files.ts create mode 100644 api/lib/password.ts create mode 100644 api/lib/settings.ts create mode 100644 api/lib/utils.ts create mode 100644 api/lib/webhook.ts create mode 100644 api/middlewares/auth.ts create mode 100644 api/middlewares/ip-restriction.ts create mode 100644 api/middlewares/ratelimit.ts create mode 100644 api/openapi.ts create mode 100644 api/routes.ts create mode 100644 api/routes/account.ts create mode 100644 api/routes/analytics.ts create mode 100644 api/routes/api-keys.ts create mode 100644 api/routes/files.ts create mode 100644 api/routes/health.ts create mode 100644 api/routes/instance.ts create mode 100644 api/routes/invites.ts create mode 100644 api/routes/metrics.ts create mode 100644 api/routes/secret-requests.ts create mode 100644 api/routes/secrets.ts create mode 100644 api/routes/setup.ts create mode 100644 api/routes/user.ts create mode 100644 api/validations/account.ts create mode 100644 api/validations/instance.ts create mode 100644 api/validations/password.ts create mode 100644 api/validations/secret-requests.ts create mode 100644 api/validations/secrets.ts create mode 100644 api/validations/user.ts create mode 100644 banner.png create mode 100644 cli-go/.gitignore create mode 100644 cli-go/README.md create mode 100644 cli-go/go.mod create mode 100644 cli-go/go.sum create mode 100644 cli-go/main.go create mode 100644 cli/README.md create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100644 cli/src/bin.ts create mode 100644 cli/src/index.ts create mode 100644 cli/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 docs/api.md create mode 100644 docs/cli.md create mode 100644 docs/docker.md create mode 100644 docs/e2e.md create mode 100644 docs/encryption.md create mode 100644 docs/env.md create mode 100644 docs/health.md create mode 100644 docs/helm-oauth.md create mode 100644 docs/helm.md create mode 100644 docs/managed.md create mode 100644 docs/metrics.md create mode 100644 docs/sdk.md create mode 100644 docs/secret-request.md create mode 100644 docs/social-login.md create mode 100644 docs/upgrade.md create mode 100644 docs/webhook.md create mode 100644 eslint.config.js create mode 100644 helm/hemmelig/.helmignore create mode 100644 helm/hemmelig/Chart.yaml create mode 100644 helm/hemmelig/templates/NOTES.txt create mode 100644 helm/hemmelig/templates/_helpers.tpl create mode 100644 helm/hemmelig/templates/deployment.yaml create mode 100644 helm/hemmelig/templates/ingress.yaml create mode 100644 helm/hemmelig/templates/pvc.yaml create mode 100644 helm/hemmelig/templates/secret.yaml create mode 100644 helm/hemmelig/templates/service.yaml create mode 100644 helm/hemmelig/templates/serviceaccount.yaml create mode 100644 helm/hemmelig/values.yaml create mode 100644 index.html create mode 100644 logo.png create mode 100644 logo.svg create mode 100644 logo_color.png create mode 100644 mise.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20250622202257_initial_v7_0_0/migration.sql create mode 100644 prisma/migrations/20250623063739_change_fields/migration.sql create mode 100644 prisma/migrations/20250626183203_add_username/migration.sql create mode 100644 prisma/migrations/20250626192902_username/migration.sql create mode 100644 prisma/migrations/20250629090604_to_number/migration.sql create mode 100644 prisma/migrations/20250629093304_secret_and_title_to_binary/migration.sql create mode 100644 prisma/migrations/20250629184216_expire_at_date/migration.sql create mode 100644 prisma/migrations/20250703203028_add_user_to_secret/migration.sql create mode 100644 prisma/migrations/20250705203515_add_file_model/migration.sql create mode 100644 prisma/migrations/20250705204724_secret_files_many_to_many/migration.sql create mode 100644 prisma/migrations/20250706192848_add_user_role_and_ban_fields/migration.sql create mode 100644 prisma/migrations/20250707062129_add_instance_settings/migration.sql create mode 100644 prisma/migrations/20250707203934_add_tracking_model/migration.sql create mode 100644 prisma/migrations/20250707211019_remove_tracking_model/migration.sql create mode 100644 prisma/migrations/20250720173355_salt/migration.sql create mode 100644 prisma/migrations/20251202061039_latest/migration.sql create mode 100644 prisma/migrations/20251202182146_remove_email_settings/migration.sql create mode 100644 prisma/migrations/20251202184727_add_visitor_analytics/migration.sql create mode 100644 prisma/migrations/20251202190532_add_organization_features/migration.sql create mode 100644 prisma/migrations/20251202192446_remove_require_approval/migration.sql create mode 100644 prisma/migrations/20251203150000_add_organization_fields/migration.sql create mode 100644 prisma/migrations/20251203150440_new/migration.sql create mode 100644 prisma/migrations/20251203210527_add_unique_username/migration.sql create mode 100644 prisma/migrations/20251203212204_add_webhook_notifications/migration.sql create mode 100644 prisma/migrations/20251204171251_add_api_keys/migration.sql create mode 100644 prisma/migrations/20251204204518_add_two_factor_authentication/migration.sql create mode 100644 prisma/migrations/20251204205345_fix_two_factor_schema/migration.sql create mode 100644 prisma/migrations/20251205130939_add_important_message/migration.sql create mode 100644 prisma/migrations/20251212205031_new/migration.sql create mode 100644 prisma/migrations/20251213160602_add_metrics_settings/migration.sql create mode 100644 prisma/migrations/20251214110935_secret_request/migration.sql create mode 100644 prisma/migrations/20251222124844_disable_file_upload/migration.sql create mode 100644 prisma/migrations/20251222130030_add_disable_email_password_signup/migration.sql create mode 100644 prisma/migrations/20251228172753_add_instance_logo/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 public/favicon.ico create mode 100644 public/icons/android-chrome-192x192.png create mode 100644 public/icons/android-chrome-512x512.png create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/favicon-16x16.png create mode 100644 public/icons/favicon-32x32.png create mode 100644 public/icons/icon-128x128.png create mode 100644 public/icons/icon-144x144.png create mode 100644 public/icons/icon-152x152.png create mode 100644 public/icons/icon-192x192.png create mode 100644 public/icons/icon-384x384.png create mode 100644 public/icons/icon-512x512.png create mode 100644 public/icons/icon-72x72.png create mode 100644 public/icons/icon-96x96.png create mode 100644 public/icons/maskable-icon-192x192.png create mode 100644 public/manifest.json create mode 100644 public/robots.txt create mode 100644 scripts/admin.ts create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/seed-demo.ts create mode 100755 scripts/update-packages.sh create mode 100644 server.ts create mode 100644 src/App.tsx create mode 100644 src/components/AddUserModal.tsx create mode 100644 src/components/Card.tsx create mode 100644 src/components/CreateButton.tsx create mode 100644 src/components/EditUserModal.tsx create mode 100644 src/components/Editor.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/ErrorDisplay.tsx create mode 100644 src/components/ExpirationSelect.tsx create mode 100644 src/components/FileUpload.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/ImportantAlert.tsx create mode 100644 src/components/LanguagePicker.tsx create mode 100644 src/components/Layout/DashboardLayout.tsx create mode 100644 src/components/Layout/RootLayout.tsx create mode 100644 src/components/Logo.tsx create mode 100644 src/components/Modal.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/SecretForm.tsx create mode 100644 src/components/SecretSettings.tsx create mode 100644 src/components/SecuritySettings.tsx create mode 100644 src/components/SocialLoginButtons.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/TitleField.tsx create mode 100644 src/components/ToggleSwitch.tsx create mode 100644 src/components/ToggleSwitchRow.tsx create mode 100644 src/components/ViewsSlider.tsx create mode 100644 src/hooks/useCopyFeedback.ts create mode 100644 src/hooks/useModalState.ts create mode 100644 src/i18n/i18n.ts create mode 100644 src/i18n/locales/da/da.json create mode 100644 src/i18n/locales/de/de.json create mode 100644 src/i18n/locales/en/en.json create mode 100644 src/i18n/locales/es/es.json create mode 100644 src/i18n/locales/fr/fr.json create mode 100644 src/i18n/locales/it/it.json create mode 100644 src/i18n/locales/nl/nl.json create mode 100644 src/i18n/locales/no/no.json create mode 100644 src/i18n/locales/sv/sv.json create mode 100644 src/i18n/locales/zh/zh.json create mode 100644 src/index.css create mode 100644 src/lib/analytics.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/hash.ts create mode 100644 src/logo.svg create mode 100644 src/main.tsx create mode 100644 src/pages/Dashboard/Account/DangerZoneTab.tsx create mode 100644 src/pages/Dashboard/Account/DeveloperTab.tsx create mode 100644 src/pages/Dashboard/Account/ProfileTab.tsx create mode 100644 src/pages/Dashboard/Account/SecurityTab.tsx create mode 100644 src/pages/Dashboard/Account/index.ts create mode 100644 src/pages/Dashboard/AccountPage.tsx create mode 100644 src/pages/Dashboard/AnalyticsPage.tsx create mode 100644 src/pages/Dashboard/CreateSecretRequestPage.tsx create mode 100644 src/pages/Dashboard/InstancePage.tsx create mode 100644 src/pages/Dashboard/InvitesPage.tsx create mode 100644 src/pages/Dashboard/SecretRequestsPage.tsx create mode 100644 src/pages/Dashboard/SecretsPage.tsx create mode 100644 src/pages/Dashboard/UsersPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/NotFoundPage.tsx create mode 100644 src/pages/PrivacyPage.tsx create mode 100644 src/pages/RegisterPage.tsx create mode 100644 src/pages/RequestSecretPage.tsx create mode 100644 src/pages/SecretNotFoundPage.tsx create mode 100644 src/pages/SecretPage.tsx create mode 100644 src/pages/SetupPage.tsx create mode 100644 src/pages/TermsPage.tsx create mode 100644 src/pages/Verify2FAPage.tsx create mode 100644 src/router.tsx create mode 100644 src/store/accountStore.ts create mode 100644 src/store/errorStore.ts create mode 100644 src/store/hemmeligStore.ts create mode 100644 src/store/secretRequestStore.ts create mode 100644 src/store/secretSettingsStore.ts create mode 100644 src/store/secretStore.ts create mode 100644 src/store/themeStore.ts create mode 100644 src/store/userStore.ts create mode 100644 src/store/usersStore.ts create mode 100644 src/utils/clipboard.ts create mode 100644 src/utils/clsx.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/password.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts create mode 100644 tests/e2e/home.spec.ts create mode 100644 tests/e2e/navigation.spec.ts create mode 100644 tests/e2e/password-change.spec.ts create mode 100644 tests/e2e/secret.spec.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9706474 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules + +# Git +.git +.gitignore + +# GitHub +.github + +# Build artifacts +dist +*.tsbuildinfo + +# Logs and backups +*.log +example_request_do.log +hemmelig.backup.db + +# Media and docs +*.mp4 +desktop.gif +desktop.png +banner.png +logo.png +logo.svg +logo_color.png +docs/ +README.md +LICENSE +CLAUDE.md +GEMINI.md + +# Dev files +bin/ +.env* +.eslintcache +*.local +helm/ + +# Generated +prisma/generated/* + +# Uploads (runtime data) +uploads/ + +# IDE +.vscode +.idea +*.swp +*.swo + +# Test files +api/tests/ +*.test.ts +*.spec.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1fb322 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +[*] +indent_style = space +indent_size = 4 + +[*.json] +indent_size = 2 + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..516ca5e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bjarneo diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..0e451c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,25 @@ +name: 🐛 Bug +description: Report an issue to help improve the project. +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the issue, also include what you tried and what didn't work + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this bug? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a49eab2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..bec9157 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,26 @@ +name: 📄 Documentation issue +description: Found an issue in the documentation? +title: "[DOCS] " +labels: ["documentation"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the issue, also include what you tried and what didn't work + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this issue? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e630cd6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: 💡Feature Request +description: Have a new idea/feature? Please suggest! +title: "[FEATURE] " +labels: + ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the enhancement you propose, also include what you tried and what worked. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this idea? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/other.yml b/.github/ISSUE_TEMPLATE/other.yml new file mode 100644 index 0000000..2dba8a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.yml @@ -0,0 +1,22 @@ +name: Other +description: Use this for any other issues. Avoid creating blank issues +title: "[OTHER]" + +body: + - type: markdown + attributes: + value: "# Other issue" + - type: textarea + id: issuedescription + attributes: + label: What would you like to share? + description: Provide a clear and concise explanation of your issue. + validations: + required: true + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this issue? + validations: + required: false \ No newline at end of file diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000..5431226 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,168 @@ +name: CLI Release + +on: + push: + tags: + - 'cli-v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + build: + name: Build CLI + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + suffix: linux-amd64 + - goos: linux + goarch: arm64 + suffix: linux-arm64 + - goos: darwin + goarch: amd64 + suffix: darwin-amd64 + - goos: darwin + goarch: arm64 + suffix: darwin-arm64 + - goos: windows + goarch: amd64 + suffix: windows-amd64.exe + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + working-directory: cli-go + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + go build -ldflags="-s -w" -o hemmelig-${{ matrix.suffix }} . + + - name: Generate SHA256 + working-directory: cli-go + run: | + sha256sum hemmelig-${{ matrix.suffix }} > hemmelig-${{ matrix.suffix }}.sha256 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: hemmelig-${{ matrix.suffix }} + path: | + cli-go/hemmelig-${{ matrix.suffix }} + cli-go/hemmelig-${{ matrix.suffix }}.sha256 + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -type f -exec cp {} release/ \; + ls -la release/ + + - name: Generate checksums file + working-directory: release + run: | + cat *.sha256 > checksums.txt + cat checksums.txt + + - name: Get version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/cli-v}" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: cli-v${{ steps.version.outputs.version }} + name: Hemmelig CLI v${{ steps.version.outputs.version }} + draft: false + prerelease: false + files: | + release/hemmelig-* + release/checksums.txt + body: | + ## Hemmelig CLI v${{ steps.version.outputs.version }} + + Create encrypted, self-destructing secrets from the command line. + + ### Installation + + #### Linux (amd64) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-linux-amd64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### Linux (arm64) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-linux-arm64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### macOS (Apple Silicon) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-darwin-arm64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### macOS (Intel) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-darwin-amd64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### Windows + Download `hemmelig-windows-amd64.exe` and add it to your PATH. + + ### Verify Download + + ```bash + # Download checksums + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/checksums.txt -o checksums.txt + + # Verify (Linux/macOS) + sha256sum -c checksums.txt --ignore-missing + ``` + + ### Usage + + ```bash + hemmelig "my secret message" + hemmelig "my secret" -t "Title" -e 7d -v 3 + cat file.txt | hemmelig + ``` + + See the [CLI documentation](https://github.com/HemmeligOrg/Hemmelig.app/blob/main/docs/cli.md) for more options. diff --git a/.github/workflows/publish_docker_image.yaml b/.github/workflows/publish_docker_image.yaml new file mode 100644 index 0000000..b69a0c1 --- /dev/null +++ b/.github/workflows/publish_docker_image.yaml @@ -0,0 +1,59 @@ +name: Publish Docker image +on: + release: + types: [published] +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get latest git tag + id: latest_tag + uses: 'WyriHaximus/github-action-get-previous-tag@v1' + with: + fallback: no-tag + + - name: Get short SHA + id: short_sha + run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Get major version + id: major_version + run: | + echo "version=$(echo ${{ steps.latest_tag.outputs.tag }} | cut -d'.' -f1)" >> $GITHUB_OUTPUT + + - name: Get minor version + id: minor_version + run: | + echo "version=$(echo ${{ steps.latest_tag.outputs.tag }} | cut -d'.' -f1,2)" >> $GITHUB_OUTPUT + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + hemmeligapp/hemmelig:${{ steps.latest_tag.outputs.tag }} + hemmeligapp/hemmelig:${{ steps.minor_version.outputs.version }} + hemmeligapp/hemmelig:${{ steps.major_version.outputs.version }} + build-args: | + GIT_SHA=${{ steps.short_sha.outputs.sha }} + GIT_TAG=${{ steps.latest_tag.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2d6c3c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Generate Release Notes + +on: + release: + types: [created] + +jobs: + update-release-notes: + name: Update Release with Commit Notes + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous tag + id: prev_tag + run: | + # Get all tags sorted by version, exclude the current tag, take the first result + CURRENT_TAG="${{ github.event.release.tag_name }}" + PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -n 1) + + if [ -z "$PREV_TAG" ]; then + echo "No previous tag found, will use initial commit" + # Use the initial commit as the starting point + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + + echo "Previous tag/commit: $PREV_TAG" + echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Generate commit list + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO_URL="https://github.com/${{ github.repository }}" + CURRENT_TAG="${{ github.event.release.tag_name }}" + PREV_TAG="${{ steps.prev_tag.outputs.tag }}" + + # Always use range format - PREV_TAG is guaranteed to be set (either previous tag or initial commit) + RANGE="${PREV_TAG}..${CURRENT_TAG}" + + echo "Generating commits for range: $RANGE" + + # Generate commit list with format: title - @nickname - sha with link + # Using tformat instead of format to ensure trailing newline + COMMITS="" + while IFS='|' read -r SHA SHORT_SHA TITLE AUTHOR || [ -n "$SHA" ]; do + [ -z "$SHA" ] && continue + + # Try to get GitHub username from commit API + USERNAME=$(gh api "repos/${{ github.repository }}/commits/${SHA}" --jq '.author.login' 2>/dev/null || echo "") + + if [ -n "$USERNAME" ]; then + AUTHOR_INFO="@${USERNAME}" + else + AUTHOR_INFO="${AUTHOR}" + fi + + # Check if this is a PR merge commit + if [[ "$TITLE" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + # Get PR title + PR_TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title' 2>/dev/null || echo "$TITLE") + COMMITS="${COMMITS}- ${PR_TITLE} (#${PR_NUMBER}) by ${AUTHOR_INFO} ([\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHA}))"$'\n' + else + COMMITS="${COMMITS}- ${TITLE} by ${AUTHOR_INFO} ([\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHA}))"$'\n' + fi + done < <(git log "${RANGE}" --pretty=tformat:"%H|%h|%s|%an") + + # Handle multiline output + { + echo "list<> $GITHUB_OUTPUT + + - name: Update Release Notes + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + body: | + ## What's Changed + + ${{ steps.commits.outputs.list }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.event.release.tag_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + update-dockerhub-description: + name: Update Docker Hub Description + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Update Docker Hub description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: hemmeligapp/hemmelig + readme-filepath: ./docs/docker.md diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000..c8bf907 --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,51 @@ +name: Trivy - Scan +on: + schedule: + # https://crontab.guru/daily + - cron: '0 0 * * *' + pull_request: +jobs: + scan_repository: + name: Scan the repository + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' + + scan_vulnerabilities: + name: Scan the docker image + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build an image from Dockerfile + run: | + docker build -t docker.io/hemmeligorg/hemmelig:${{ github.sha }} . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: 'docker.io/hemmeligorg/hemmelig:${{ github.sha }}' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: 'trivy-results.sarif' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d914a --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +.env +cli/node_modules +cli/dist +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.env.private +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# OS generated files +.DS_Store +.Trashes +ehthumbs.db +Thumbs.db + +build/ + +hemmelig.yaml + +size-plugin.json + +.env.local + +hemmelig.db* +uploads/ +prisma_test.js +database/ +data/ +.vscode +hemmelig.backup.db +client/build/ +api/**/*.js +api/*.js +.idea +prisma/generated/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..487263b --- /dev/null +++ b/.npmignore @@ -0,0 +1,9 @@ +src/client +src/server +src/*.js +server.js +config/ +public/ +.github/ +tests/ +hemmelig.backup.db \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2a0f707 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +dist +build +.husky +bun.lock +package-lock.json +*.min.js +*.min.css +prisma/migrations +.github +helm/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e634b77 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "arrowParens": "always", + "jsxSingleQuote": false, + "printWidth": 100, + "plugins": ["prettier-plugin-prisma", "prettier-plugin-organize-imports"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d796a6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,640 @@ +# Claude AI Assistant Guidelines for Hemmelig.app + +Welcome to Hemmelig.app! This guide will help you navigate our codebase and contribute effectively. Think of this document as your onboarding buddy - it covers everything you need to know to maintain code quality, security, and architectural consistency. + +## What is Hemmelig? + +Hemmelig.app is a secure secret-sharing application that lets users share encrypted messages that automatically self-destruct after being read. The name "Hemmelig" means "secret" in Norwegian - fitting, right? + +### The Security Model You Must Understand + +**CRITICAL: Zero-Knowledge Architecture** + +This is the heart of Hemmelig. Before you write a single line of code, make sure you understand this: + +- All encryption/decryption happens **client-side only** using the Web Crypto API +- The server **never** sees plaintext secrets - only encrypted blobs +- Decryption keys live in URL fragments (`#decryptionKey=...`), which browsers **never send to servers** +- This is our fundamental security promise to users - **do not compromise this under any circumstances** + +### How Encryption Works + +| Component | Details | +| ------------------ | ---------------------------------------------------------- | +| **Algorithm** | AES-256-GCM (authenticated encryption) | +| **Key Derivation** | PBKDF2 with SHA-256, 1,300,000 iterations | +| **IV** | 96-bit random initialization vector per encryption | +| **Salt** | 32-character random string per secret (stored server-side) | +| **Implementation** | `src/lib/crypto.ts` | + +## Technology Stack + +Here's what powers Hemmelig: + +| Layer | Technology | Notes | +| -------------- | ---------------------------- | ------------------------------------------ | +| **Runtime** | Node.js 25 | JavaScript runtime for dev and production | +| **Frontend** | React 19 + Vite + TypeScript | All components use `.tsx` | +| **Backend** | Hono (RPC mode) | Type-safe API client generation | +| **Database** | SQLite + Prisma ORM | Schema in `prisma/schema.prisma` | +| **Styling** | Tailwind CSS v4 | Class-based, light/dark mode support | +| **State** | Zustand | Lightweight state management | +| **Auth** | better-auth | Session-based with 2FA support | +| **i18n** | react-i18next | All user-facing strings must be translated | +| **Monitoring** | prom-client | Prometheus metrics | +| **API Docs** | Swagger UI | OpenAPI documentation | + +## Project Structure + +Here's how the codebase is organized: + +``` +hemmelig.app/ +├── api/ # Backend (Hono) +│ ├── app.ts # Main Hono application setup +│ ├── auth.ts # Authentication configuration +│ ├── config.ts # Application configuration +│ ├── openapi.ts # OpenAPI/Swagger spec & UI +│ ├── routes.ts # Route aggregator +│ ├── routes/ # Individual route handlers +│ │ ├── secrets.ts # Secret CRUD operations +│ │ ├── secret-requests.ts # Secret request management +│ │ ├── account.ts # User account management +│ │ ├── files.ts # File upload/download +│ │ ├── user.ts # User management (admin) +│ │ ├── instance.ts # Instance settings +│ │ ├── analytics.ts # Usage analytics +│ │ ├── invites.ts # Invite code management +│ │ ├── api-keys.ts # API key management +│ │ ├── setup.ts # Initial setup flow +│ │ ├── health.ts # Health check endpoints +│ │ └── metrics.ts # Prometheus metrics +│ ├── lib/ # Backend utilities +│ │ ├── db.ts # Prisma client singleton +│ │ ├── password.ts # Password hashing (Argon2) +│ │ ├── files.ts # File handling utilities +│ │ ├── settings.ts # Instance settings helper +│ │ ├── webhook.ts # Webhook dispatch utilities +│ │ ├── analytics.ts # Analytics utilities +│ │ ├── constants.ts # Shared constants +│ │ └── utils.ts # General utilities +│ ├── middlewares/ # Hono middlewares +│ │ ├── auth.ts # Authentication middleware +│ │ ├── ratelimit.ts # Rate limiting +│ │ └── ip-restriction.ts # IP allowlist/blocklist +│ ├── validations/ # Zod schemas for request validation +│ └── jobs/ # Background jobs (cleanup, etc.) +├── src/ # Frontend (React) +│ ├── components/ # Reusable UI components +│ │ ├── Layout/ # Layout wrappers +│ │ ├── Editor.tsx # TipTap rich text editor +│ │ └── ... +│ ├── pages/ # Route-level components +│ │ ├── HomePage.tsx +│ │ ├── SecretPage.tsx +│ │ ├── SetupPage.tsx # Initial admin setup +│ │ ├── Verify2FAPage.tsx # Two-factor verification +│ │ ├── Dashboard/ # Admin dashboard pages +│ │ └── ... +│ ├── store/ # Zustand stores +│ │ ├── secretStore.ts # Secret creation state +│ │ ├── userStore.ts # Current user state +│ │ ├── hemmeligStore.ts # Instance settings +│ │ ├── themeStore.ts # Light/dark mode (persisted) +│ │ └── ... +│ ├── lib/ # Frontend utilities +│ │ ├── api.ts # Hono RPC client +│ │ ├── auth.ts # better-auth client +│ │ ├── crypto.ts # Client-side encryption +│ │ ├── hash.ts # Hashing utilities +│ │ └── analytics.ts # Page view tracking +│ ├── i18n/ # Internationalization +│ │ └── locales/ # Translation JSON files +│ └── router.tsx # React Router configuration +├── prisma/ +│ └── schema.prisma # Database schema +├── scripts/ # Utility scripts +│ ├── admin.ts # Set user as admin +│ └── seed-demo.ts # Seed demo data +├── server.ts # Production server entry point +└── vite.config.ts # Vite configuration +``` + +## Getting Started + +### Development Commands + +```bash +# Install dependencies +npm install + +# Start frontend with hot reload +npm run dev + +# Start API server (runs migrations automatically) +npm run dev:api + +# Build for production +npm run build + +# Run production server +npm run start + +# Database commands +npm run migrate:dev # Create and apply migrations +npm run migrate:deploy # Apply pending migrations (production) +npm run migrate:reset # Reset database (destructive!) +npm run migrate:status # Check migration status + +# Utility scripts +npm run set:admin # Promote a user to admin +npm run seed:demo # Seed database with demo data + +# Code quality +npm run format # Format code with Prettier +npm run format:check # Check formatting +npm run test:e2e # Run end-to-end tests with Playwright +``` + +## Coding Guidelines + +### Core Principles + +1. **Make Surgical Changes** - Only change what's necessary. Don't refactor, optimize, or "improve" unrelated code. + +2. **Follow Existing Patterns** - Consistency trumps personal preference. Match what's already in the codebase. + +3. **Ask Before Adding Dependencies** - Never add, remove, or update packages without explicit permission. + +4. **Security First** - Extra scrutiny for anything touching encryption, authentication, or data handling. + +--- + +## Frontend Guidelines + +### Component Structure + +Keep your components clean and consistent: + +```tsx +// Use functional components with hooks +export function MyComponent({ prop1, prop2 }: MyComponentProps) { + const { t } = useTranslation(); // Always use i18n for user-facing text + const [state, setState] = useState(initialValue); + + const handleAction = () => { + // Event handler logic + }; + + return
{/* Always support light/dark mode */}
; +} +``` + +### Design System + +Our UI follows these principles: + +- **Compact design** with minimal padding +- **Sharp corners** - no `rounded-*` classes +- **Light and dark mode** support is mandatory +- **Mobile-first** responsive design + +### Styling with Tailwind + +```tsx +// GOOD: Light mode first, then dark variant, sharp corners +className = + 'bg-white dark:bg-dark-800 text-gray-900 dark:text-white border border-gray-200 dark:border-dark-600'; + +// BAD: Missing light mode variant +className = 'dark:bg-dark-800'; + +// BAD: Using rounded corners +className = 'rounded-lg'; + +// BAD: Using arbitrary values when design tokens exist +className = 'bg-[#111111]'; // Use bg-dark-800 instead +``` + +### Custom Color Palette + +```javascript +// tailwind.config.js defines these colors: +dark: { + 900: '#0a0a0a', // Darkest background + 800: '#111111', // Card backgrounds + 700: '#1a1a1a', // Input backgrounds + 600: '#222222', // Borders + 500: '#2a2a2a', // Lighter borders +} +light: { + 900: '#ffffff', + 800: '#f8fafc', + 700: '#f1f5f9', + 600: '#e2e8f0', + 500: '#cbd5e1', +} +``` + +### State Management with Zustand + +```typescript +// src/store/exampleStore.ts +import { create } from 'zustand'; + +interface ExampleState { + data: string; + setData: (data: string) => void; +} + +export const useExampleStore = create((set) => ({ + data: '', + setData: (data) => set({ data }), +})); +``` + +### API Calls + +Always use the typed Hono RPC client - it gives you full type safety: + +```typescript +import { api } from '../lib/api'; + +// The client is fully typed based on backend routes +const response = await api.secrets.$post({ + json: { secret: encryptedData, expiresAt: timestamp }, +}); +const data = await response.json(); +``` + +### React Router Loaders + +We use React Router v7 with data loaders. Here's the pattern: + +```typescript +// In router.tsx +{ + path: '/dashboard/secrets', + element: , + loader: async () => { + const res = await api.secrets.$get(); + return await res.json(); + }, +} + +// In the component +import { useLoaderData } from 'react-router-dom'; + +export function SecretsPage() { + const secrets = useLoaderData(); + // Use the pre-loaded data +} +``` + +--- + +## Internationalization (i18n) + +### The Golden Rule + +**When you add or modify any user-facing string, you MUST update ALL translation files.** + +### Supported Languages + +We currently support these languages: + +| Language | File Path | +| --------- | ----------------------------- | +| English | `src/i18n/locales/en/en.json` | +| Danish | `src/i18n/locales/da/da.json` | +| German | `src/i18n/locales/de/de.json` | +| Spanish | `src/i18n/locales/es/es.json` | +| French | `src/i18n/locales/fr/fr.json` | +| Italian | `src/i18n/locales/it/it.json` | +| Dutch | `src/i18n/locales/nl/nl.json` | +| Norwegian | `src/i18n/locales/no/no.json` | +| Swedish | `src/i18n/locales/sv/sv.json` | +| Chinese | `src/i18n/locales/zh/zh.json` | + +### How to Add New Strings + +1. **Add to English first** - `src/i18n/locales/en/en.json` +2. **Add to ALL other locale files** - Even if you use English as a placeholder, add the key to every file +3. **Use nested keys** - Follow the existing structure (e.g., `secret_page.loading_message`) + +```tsx +// Using translations in components +const { t } = useTranslation(); + +// GOOD +

{t('secret_page.loading_message')}

+ +// BAD: Hardcoded string +

Loading...

+``` + +### Translation Checklist + +Before committing, verify: + +- [ ] Added key to `en/en.json` with proper English text +- [ ] Added key to `da/da.json` +- [ ] Added key to `de/de.json` +- [ ] Added key to `es/es.json` +- [ ] Added key to `fr/fr.json` +- [ ] Added key to `it/it.json` +- [ ] Added key to `nl/nl.json` +- [ ] Added key to `no/no.json` +- [ ] Added key to `sv/sv.json` +- [ ] Added key to `zh/zh.json` + +> **Tip**: If you don't know the translation, use the English text as a placeholder and add a `// TODO: translate` comment in your PR description. + +--- + +## Backend Guidelines + +### Route Structure + +```typescript +// api/routes/example.ts +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; + +const exampleRoute = new Hono().post( + '/', + zValidator( + 'json', + z.object({ + field: z.string().min(1).max(1000), + }) + ), + async (c) => { + const { field } = c.req.valid('json'); + // Handler logic here + return c.json({ success: true }); + } +); + +export default exampleRoute; +``` + +### Database Operations + +```typescript +// Always use the Prisma client from api/lib/db.ts +import prisma from '../lib/db'; + +// Use transactions for multiple operations +await prisma.$transaction([ + prisma.secrets.create({ data: { ... } }), + prisma.file.createMany({ data: files }), +]); +``` + +### Input Validation + +- **Always** validate input using Zod schemas +- Place reusable schemas in `api/validations/` +- Validate at the route level using `zValidator` + +### Error Handling + +```typescript +// Return consistent error responses +return c.json({ error: 'Descriptive error message' }, 400); + +// Zod automatically handles validation errors +``` + +### Database Schema Changes + +1. Modify `prisma/schema.prisma` +2. Run `npm run migrate:dev --name descriptive_name` +3. Test the migration locally +4. Commit both schema and migration files + +--- + +## Health & Monitoring + +### Health Endpoints + +Hemmelig provides Kubernetes-ready health checks: + +| Endpoint | Purpose | Checks | +| ------------------- | --------------- | ------------------------- | +| `GET /health/live` | Liveness probe | Process is running | +| `GET /health/ready` | Readiness probe | Database, storage, memory | + +### Metrics Endpoint + +Prometheus metrics are available at `GET /metrics`. This endpoint can be optionally protected with authentication. + +--- + +## Security Checklist + +When modifying security-sensitive code, verify: + +- [ ] Encryption/decryption remains client-side only +- [ ] Decryption keys never reach the server +- [ ] Input validation is present on all endpoints +- [ ] Authentication checks are in place where required +- [ ] Rate limiting is applied to sensitive endpoints +- [ ] No sensitive data in logs or error messages +- [ ] File uploads are validated and sanitized + +--- + +## Testing + +- End-to-end tests use [Playwright](https://playwright.dev/) - find them in `tests/e2e/` +- Run e2e tests with `npm run test:e2e` +- When adding new features, add corresponding test files + +--- + +## Common Patterns + +### Creating a New Page + +1. Create component in `src/pages/` +2. Add route with loader in `src/router.tsx` +3. **Add translations to ALL locale files** +4. Ensure light/dark mode support + +### Creating a New API Endpoint + +1. Add route handler in `api/routes/` +2. Register in `api/routes.ts` +3. Add Zod validation schema +4. Frontend types update automatically via Hono RPC + +### Adding a New Store + +1. Create store in `src/store/` +2. Follow existing Zustand patterns +3. Export from the store file + +--- + +## What NOT to Do + +These are hard rules - no exceptions: + +1. **Never** modify encryption logic without explicit approval +2. **Never** log or store plaintext secrets server-side +3. **Never** send decryption keys to the server +4. **Never** bypass input validation +5. **Never** add dependencies without approval +6. **Never** modify unrelated code "while you're in there" +7. **Never** use `any` types in TypeScript +8. **Never** commit `.env` files or secrets +9. **Never** disable security features "temporarily" +10. **Never** add user-facing strings without updating ALL translation files + +--- + +## Quick Reference + +### Key File Locations + +| What | Where | +| ---------------------- | ---------------------- | +| Frontend components | `src/components/` | +| Page components | `src/pages/` | +| API routes | `api/routes/` | +| API config | `api/config.ts` | +| Database schema | `prisma/schema.prisma` | +| Translations | `src/i18n/locales/` | +| Stores | `src/store/` | +| Client-side encryption | `src/lib/crypto.ts` | +| API client | `src/lib/api.ts` | +| Backend setup | `api/app.ts` | +| Design tokens | `tailwind.config.js` | + +### Environment Variables + +```bash +# Required +DATABASE_URL= # SQLite connection (file:./data/hemmelig.db) +BETTER_AUTH_SECRET= # Auth secret key (generate a random string) +BETTER_AUTH_URL= # Public URL of your instance + +# Optional - Server +HEMMELIG_PORT= # Port the server listens on (default: 3000) +HEMMELIG_BASE_URL= # Public URL (required for OAuth callbacks) +HEMMELIG_TRUSTED_ORIGIN= # Additional trusted origin for CORS + +# Optional - Analytics +HEMMELIG_ANALYTICS_ENABLED= # Enable/disable analytics tracking (default: true) +HEMMELIG_ANALYTICS_HMAC_SECRET= # HMAC secret for anonymizing visitor IDs + +# Optional - Managed Mode (all instance settings via env vars) +HEMMELIG_MANAGED= # Enable managed mode (true/false) +``` + +See `docs/env.md` for the full environment variable reference. + +--- + +## Managed Mode + +When `HEMMELIG_MANAGED=true`, all instance settings are controlled via environment variables instead of the database. The admin dashboard becomes read-only. + +### Managed Mode Environment Variables + +| Variable | Description | Default | +| ---------------------------------------- | ------------------------------------------- | ------- | +| `HEMMELIG_MANAGED` | Enable managed mode | `false` | +| `HEMMELIG_INSTANCE_NAME` | Display name for your instance | `""` | +| `HEMMELIG_INSTANCE_DESCRIPTION` | Description shown on the homepage | `""` | +| `HEMMELIG_INSTANCE_LOGO` | Base64-encoded logo image (max 512KB) | `""` | +| `HEMMELIG_ALLOW_REGISTRATION` | Allow new user signups | `true` | +| `HEMMELIG_REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` | +| `HEMMELIG_DEFAULT_SECRET_EXPIRATION` | Default expiration in hours | `72` | +| `HEMMELIG_MAX_SECRET_SIZE` | Max secret size in KB | `1024` | +| `HEMMELIG_IMPORTANT_MESSAGE` | Alert banner shown to all users | `""` | +| `HEMMELIG_ALLOW_PASSWORD_PROTECTION` | Allow password-protected secrets | `true` | +| `HEMMELIG_ALLOW_IP_RESTRICTION` | Allow IP range restrictions | `true` | +| `HEMMELIG_ALLOW_FILE_UPLOADS` | Allow users to attach files | `true` | +| `HEMMELIG_ENABLE_RATE_LIMITING` | Enable API rate limiting | `true` | +| `HEMMELIG_RATE_LIMIT_REQUESTS` | Max requests per window | `100` | +| `HEMMELIG_RATE_LIMIT_WINDOW` | Rate limit window in seconds | `60` | +| `HEMMELIG_REQUIRE_INVITE_CODE` | Require invite code for registration | `false` | +| `HEMMELIG_ALLOWED_EMAIL_DOMAINS` | Comma-separated allowed domains | `""` | +| `HEMMELIG_REQUIRE_REGISTERED_USER` | Only registered users create secrets | `false` | +| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup (social only) | `false` | +| `HEMMELIG_WEBHOOK_ENABLED` | Enable webhook notifications | `false` | +| `HEMMELIG_WEBHOOK_URL` | Webhook endpoint URL | `""` | +| `HEMMELIG_WEBHOOK_SECRET` | HMAC secret for webhook signatures | `""` | +| `HEMMELIG_WEBHOOK_ON_VIEW` | Send webhook when secret is viewed | `true` | +| `HEMMELIG_WEBHOOK_ON_BURN` | Send webhook when secret is burned | `true` | +| `HEMMELIG_METRICS_ENABLED` | Enable Prometheus metrics endpoint | `false` | +| `HEMMELIG_METRICS_SECRET` | Bearer token for `/api/metrics` | `""` | + +See `docs/managed.md` for full documentation. + +--- + +## Feature Reference + +### Organization Features + +Hemmelig supports enterprise deployments with: + +- **Invite-Only Registration** - Require invite codes to sign up +- **Email Domain Restrictions** - Limit signups to specific domains (e.g., `company.com`) +- **Instance Settings** - Configure `allowRegistration`, `requireInvite`, `allowedEmailDomains` + +### Analytics System + +Privacy-focused analytics with: + +- HMAC-SHA256 hashing for anonymous visitor IDs (IPs never stored) +- Automatic bot filtering using `isbot` +- Tracks: Homepage (`/`) and Secret view page (`/secret`) +- Admin dashboard at `/dashboard/analytics` + +### Authentication + +better-auth provides: + +- Session-based authentication +- Two-factor authentication (2FA) with TOTP +- Custom signup hooks for email domain validation +- Admin user management +- Social login providers (GitHub, Google, Microsoft, Discord, GitLab, Apple, Twitter) + +### Social Login Providers + +Social login can be configured via: + +1. **Admin Dashboard** - Instance Settings > Social Login tab (when not in managed mode) +2. **Environment Variables** - Using `HEMMELIG_AUTH_*` variables (always available) + +Environment variable format: + +```bash +HEMMELIG_AUTH_GITHUB_ID=your-client-id +HEMMELIG_AUTH_GITHUB_SECRET=your-client-secret +HEMMELIG_AUTH_GOOGLE_ID=your-client-id +HEMMELIG_AUTH_GOOGLE_SECRET=your-client-secret +# ... etc for microsoft, discord, gitlab, apple, twitter +``` + +See `docs/social-login.md` for full setup instructions. + +--- + +## Need Help? + +- Check existing patterns in the codebase first +- Read related components/routes for context +- When in doubt, ask for clarification rather than making assumptions + +--- + +_This document is the source of truth for development practices in this repository._ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27dc277 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 + +# Prisma client generation stage - runs on native architecture to avoid QEMU issues +FROM --platform=$BUILDPLATFORM node:25-slim AS prisma-gen +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts +COPY prisma ./prisma +COPY prisma.config.ts ./ +ENV DATABASE_URL="file:/app/database/hemmelig.db" +RUN npx prisma generate --schema=prisma/schema.prisma --generator client + +# Build stage +FROM node:25-slim AS builder +RUN apt-get update && apt-get install -y python3 make g++ openssl ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json ./ +ENV NODE_ENV=development +RUN npm ci +COPY prisma ./prisma +COPY prisma.config.ts ./ +# Copy pre-generated Prisma client from native build +COPY --from=prisma-gen /app/prisma/generated ./prisma/generated +COPY api ./api +COPY src ./src +COPY public ./public +COPY index.html tsconfig*.json vite.config.ts tailwind.config.js ./ +COPY server.ts ./ +RUN npm run build + +# Production dependencies +FROM node:25-slim AS deps +RUN apt-get update && apt-get install -y python3 make g++ openssl ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json ./ +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./ +# Copy pre-generated Prisma client from native build +COPY --from=prisma-gen /app/prisma/generated ./prisma/generated +ENV NODE_ENV=production +RUN npm ci --omit=dev --ignore-scripts && \ + npm rebuild better-sqlite3 && \ + npm cache clean --force && \ + rm -rf /root/.npm /tmp/* + +# Final image +FROM node:25-slim +RUN apt-get update && apt-get install -y wget openssl ca-certificates gosu && rm -rf /var/lib/apt/lists/* && \ + groupadd -r app && useradd -r -g app -m -d /home/app app +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server.ts ./ +COPY --from=builder /app/api ./api +COPY --from=builder /app/prisma/schema.prisma ./prisma/schema.prisma +COPY --from=builder /app/prisma/migrations ./prisma/migrations +COPY --from=builder /app/prisma.config.ts ./ +COPY --from=deps /app/package.json ./ +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/prisma/generated ./prisma/generated +RUN mkdir -p /app/database /app/uploads && chown -R app:app /app +COPY --chown=app:app scripts/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 3000 +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATABASE_URL=file:/app/database/hemmelig.db + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health/ready || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aad3217 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +O'Saasy License Agreement +Copyright © 2025, Bjarne Øverli. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a83fac --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +
+ hemmelig +
+ +

Hemmelig - Encrypted Secret Sharing

+ +

+ Share sensitive information securely with client-side encryption and self-destructing messages. +

+ +

+ Try it online • + Deploy to terces.cloud • + Quick Start • + Docker Guide • + Configuration +

+ +

+ Docker pulls + Deploy to terces.cloud + Buy Me a Coffee +

+ +## How It Works + +1. Enter your secret on [hemmelig.app](https://hemmelig.app) or your self-hosted instance +2. Set expiration time, view limits, and optional password +3. Share the generated link with your recipient +4. The secret is automatically deleted after being viewed or expired + +**Zero-knowledge architecture:** All encryption happens in your browser. The server only stores encrypted data and never sees your secrets or encryption keys. + +## Features + +- **Client-side AES-256-GCM encryption** - Your data is encrypted before leaving your browser +- **Self-destructing secrets** - Configurable expiration and view limits +- **Password protection** - Optional additional security layer +- **IP restrictions** - Limit access to specific IP ranges +- **File uploads** - Share encrypted files (authenticated users) +- **Rich text editor** - Format your secrets with styling +- **QR codes** - Easy mobile sharing +- **Multi-language support** - Available in multiple languages +- **Webhook notifications** - Get notified when secrets are viewed or burned ([docs](docs/webhook.md)) + +## Quick Start + +### Docker (Recommended) + +```bash +docker run -d \ + --name hemmelig \ + -p 3000:3000 \ + -v hemmelig-data:/app/database \ + -v hemmelig-uploads:/app/uploads \ + -e DATABASE_URL="file:/app/database/hemmelig.db" \ + -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ + -e BETTER_AUTH_URL="https://your-domain.com" \ + hemmelig/hemmelig:v7 +``` + +### Docker Compose + +```bash +git clone https://github.com/HemmeligOrg/Hemmelig.app.git +cd Hemmelig.app + +# Edit docker-compose.yml with your settings +docker compose up -d +``` + +See [Docker Guide](docs/docker.md) for detailed deployment instructions. + +### CLI + +Create secrets directly from the command line: + +```bash +# Download the binary (recommended for CI/CD) +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v1.0.1/hemmelig-linux-amd64 -o hemmelig +chmod +x hemmelig + +# Or install via npm +npm install -g hemmelig + +# Create a secret +hemmelig "my secret message" + +# With options +hemmelig "API key: sk-1234" -t "Production API Key" -e 7d -v 3 +``` + +See [CLI Documentation](docs/cli.md) for all platforms and CI/CD integration examples. + +## Documentation + +- [Docker Deployment](docs/docker.md) - Complete Docker setup guide +- [Helm Chart](docs/helm.md) - Kubernetes deployment with Helm +- [Environment Variables](docs/env.md) - All configuration options +- [Managed Mode](docs/managed.md) - Configure instance settings via environment variables +- [CLI](docs/cli.md) - Command-line interface for automation and CI/CD +- [Encryption](docs/encryption.md) - How client-side encryption works +- [Social Login](docs/social-login.md) - OAuth provider setup (GitHub, Google, etc.) +- [Secret Requests](docs/secret-request.md) - Request secrets from others securely +- [Webhooks](docs/webhook.md) - Webhook notifications for secret events +- [Health Checks](docs/health.md) - Liveness and readiness probes for container orchestration +- [Prometheus Metrics](docs/metrics.md) - Monitor your instance with Prometheus +- [API Documentation](docs/api.md) - REST API reference and OpenAPI spec +- [SDK Generation](docs/sdk.md) - Generate client SDKs from OpenAPI spec +- [E2E Testing](docs/e2e.md) - End-to-end testing with Playwright +- [Upgrading from v6](docs/upgrade.md) - Migration guide for v6 to v7 + +## Development + +```bash +npm install +npm run dev +npm run dev:api +``` + +## Deploy to terces.cloud + +Want a hassle-free managed Hemmelig instance? [terces.cloud](https://terces.cloud) offers fully managed Hemmelig hosting for **$20/month**. Get your own private instance with automatic updates, backups, and zero maintenance overhead. + +Deploy to terces.cloud + +## Hetzner Cloud Referral + +Hemmelig is proudly hosted on [Hetzner Cloud](https://hetzner.cloud/?ref=Id028KbCZQoD). Hetzner provides reliable and scalable cloud solutions, making it an ideal choice for hosting secure applications like Hemmelig. By using our [referral link](https://hetzner.cloud/?ref=Id028KbCZQoD), you can join Hetzner Cloud and receive €20/$20 in credits. Once you spend at least €10/$10 (excluding credits), Hemmelig will receive €10/$10 in Hetzner Cloud credits. This is a great opportunity to explore Hetzner's services while supporting Hemmelig. + +## License + +O'Saasy License Agreement - Copyright © 2025, Bjarne Øverli. + +This project is licensed under a modified MIT license that prohibits using the software to compete with the original licensor as a hosted SaaS product. See [LICENSE](LICENSE) for details. diff --git a/api/app.ts b/api/app.ts new file mode 100644 index 0000000..a349137 --- /dev/null +++ b/api/app.ts @@ -0,0 +1,158 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { csrf } from 'hono/csrf'; +import { etag, RETAINED_304_HEADERS } from 'hono/etag'; +import { HTTPException } from 'hono/http-exception'; +import { logger } from 'hono/logger'; +import { requestId } from 'hono/request-id'; +import { secureHeaders } from 'hono/secure-headers'; +import { timeout } from 'hono/timeout'; +import { trimTrailingSlash } from 'hono/trailing-slash'; +import { ZodError } from 'zod'; + +import { auth } from './auth'; +import config from './config'; +import startJobs from './jobs'; +import prisma from './lib/db'; +import ratelimit from './middlewares/ratelimit'; +import routes from './routes'; + +// Initialize Hono app +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + }; +}>(); + +// Global error handler +app.onError((err, c) => { + const requestId = c.get('requestId') || 'unknown'; + + // Handle Zod validation errors + if (err instanceof ZodError) { + console.error(`[${requestId}] Validation error:`, err.flatten()); + return c.json( + { + error: 'Validation failed', + details: err.flatten().fieldErrors, + }, + 400 + ); + } + + // Handle HTTP exceptions (thrown by Hono or middleware) + if (err instanceof HTTPException) { + console.error(`[${requestId}] HTTP exception:`, { + status: err.status, + message: err.message, + }); + return c.json({ error: err.message }, err.status); + } + + // Handle all other errors + console.error(`[${requestId}] Unhandled error:`, { + error: err.message, + stack: err.stack, + }); + + // Don't expose internal error details in production + return c.json({ error: 'Internal server error' }, 500); +}); + +// Handle 404 - route not found +app.notFound((c) => { + return c.json({ error: 'Not found' }, 404); +}); + +// Start the background jobs +startJobs(); + +// Add the middlewares +// More middlewares can be found here: +// https://hono.dev/docs/middleware/builtin/basic-auth +app.use(secureHeaders()); +app.use(logger()); +app.use(trimTrailingSlash()); +app.use(`/*`, requestId()); +app.use(`/*`, timeout(15 * 1000)); // 15 seconds timeout to the API calls +app.use(ratelimit); + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag +app.use( + `/*`, + etag({ + retainedHeaders: ['x-message', ...RETAINED_304_HEADERS], + }) +); + +// Configure CORS with trusted origins +const trustedOrigins = config.get('trustedOrigins', []); +app.use( + `/*`, + cors({ + origin: trustedOrigins, + credentials: true, + }) +); + +// Configure CSRF protection (exclude auth routes for OAuth callbacks) +app.use('/*', async (c, next) => { + // Skip CSRF for auth routes (OAuth callbacks come from external origins) + if (c.req.path.startsWith('/auth/')) { + return next(); + } + return csrf({ + origin: trustedOrigins, + })(c, next); +}); + +// Custom middlewares +app.use('*', async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + + if (!session) { + c.set('user', null); + c.set('session', null); + return next(); + } + + c.set('user', session.user); + c.set('session', session.session); + return next(); +}); + +// Add the routes +app.on(['POST', 'GET'], `/auth/*`, (c) => { + return auth.handler(c.req.raw); +}); + +// Add the application routes +app.route('/', routes); + +// https://hono.dev/docs/guides/rpc#rpc +export type AppType = typeof routes; + +export default app; + +// Handle graceful shutdown +process.on('SIGINT', async () => { + await prisma.$disconnect(); + console.info('Disconnected from database'); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error: Error) => { + console.error('Uncaught Exception', { + error: error.message, + stack: error.stack, + }); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason: unknown) => { + console.error('Unhandled Rejection', { reason }); + process.exit(1); +}); diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..2c915a8 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,176 @@ +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { APIError } from 'better-auth/api'; +import { admin, twoFactor, username } from 'better-auth/plugins'; +import { genericOAuth } from 'better-auth/plugins/generic-oauth'; +import { randomBytes } from 'crypto'; +import config, { type SocialProviderConfig } from './config'; +import prisma from './lib/db'; +import { validatePassword } from './validations/password'; + +// Generate a unique username from email +const generateUsernameFromEmail = (email: string): string => { + const localPart = email.split('@')[0] || 'user'; + // Sanitize: only keep alphanumeric characters and underscores + const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(); + // Add random suffix to ensure uniqueness (cryptographically secure) + const randomSuffix = randomBytes(4).toString('hex').substring(0, 6); + return `${sanitized}_${randomSuffix}`; +}; + +// Build better-auth social providers configuration dynamically +const buildBetterAuthSocialProviders = () => { + const providers = config.getSocialProviders(); + const betterAuthProviders: Record< + string, + { + clientId: string; + clientSecret: string; + tenantId?: string; + issuer?: string; + mapProfileToUser?: (profile: { email?: string; name?: string }) => { username: string }; + } + > = {}; + + for (const [provider, providerConfig] of Object.entries(providers)) { + const typedConfig = providerConfig as SocialProviderConfig; + betterAuthProviders[provider] = { + clientId: typedConfig.clientId, + clientSecret: typedConfig.clientSecret, + ...(typedConfig.tenantId && { tenantId: typedConfig.tenantId }), + ...(typedConfig.issuer && { issuer: typedConfig.issuer }), + mapProfileToUser: (profile) => ({ + username: generateUsernameFromEmail(profile.email || profile.name || 'user'), + }), + }; + } + + return betterAuthProviders; +}; + +// Build better-auth plugins array +const buildPlugins = () => { + const plugins: any[] = [username(), admin(), twoFactor()]; + + const genericProviders = config.getGenericOAuthProviders(); + if (genericProviders.length > 0) { + plugins.push( + genericOAuth({ + config: genericProviders.map((provider) => ({ + ...provider, + // Map profile to include username + mapProfileToUser: (profile: any) => ({ + username: generateUsernameFromEmail( + profile.email || profile.name || 'user' + ), + }), + })), + }) + ); + } + + return plugins; +}; + +export const auth = betterAuth({ + appName: 'Hemmelig', + baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000', + database: prismaAdapter(prisma, { + provider: 'sqlite', + }), + emailAndPassword: { + enabled: true, + // Set to 1 so better-auth doesn't reject weak current passwords during password change. + // Password strength for new passwords is enforced by our Zod schema (updatePasswordSchema) + // and for sign-up by the before hook below. + minPasswordLength: 1, + }, + socialProviders: buildBetterAuthSocialProviders(), + account: { + accountLinking: { + enabled: true, + trustedProviders: [ + 'gitlab', + 'github', + 'google', + 'microsoft', + 'discord', + 'apple', + 'twitter', + // Add all generic OAuth provider IDs as trusted + ...config.getGenericOAuthProviders().map((p) => p.providerId), + ], + }, + }, + plugins: buildPlugins(), + trustedOrigins: config.get('trustedOrigins'), + hooks: { + before: async (context) => { + // Only apply validation to email/password sign-up + if (context.path !== '/sign-up/email') { + return; + } + + const body = context.body as { email?: string; password?: string }; + const email = body?.email; + const password = body?.password; + + if (!email) { + return; + } + + // Validate password strength for sign-up + if (password) { + const passwordError = validatePassword(password); + if (passwordError) { + throw new APIError('BAD_REQUEST', { message: passwordError }); + } + } + + // Get instance settings + const settings = await prisma.instanceSettings.findFirst({ + select: { allowedEmailDomains: true, disableEmailPasswordSignup: true }, + }); + + // Check if email/password signup is disabled + if (settings?.disableEmailPasswordSignup) { + throw new APIError('FORBIDDEN', { + message: 'Email/password registration is disabled. Please use social login.', + }); + } + + const allowedDomains = settings?.allowedEmailDomains?.trim(); + + // If no domains configured, allow all + if (!allowedDomains) { + return; + } + + // Parse comma-separated domains + const domains = allowedDomains + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); + + if (domains.length === 0) { + return; + } + + // Extract domain from email + const emailDomain = email.split('@')[1]?.toLowerCase(); + + if (!emailDomain || !domains.includes(emailDomain)) { + throw new APIError('FORBIDDEN', { + message: 'Email domain not allowed', + }); + } + }, + }, +}); + +// Export enabled social providers for frontend consumption +export const getEnabledSocialProviders = (): string[] => { + const standardProviders = Object.keys(config.getSocialProviders()); + const genericProviders = config.getGenericOAuthProviders().map((p) => p.providerId); + return [...standardProviders, ...genericProviders]; +}; diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..17bf481 --- /dev/null +++ b/api/config.ts @@ -0,0 +1,255 @@ +import dlv from 'dlv'; + +const isProduction = process.env.NODE_ENV === 'production'; + +// Helper to parse boolean from env, returns undefined if not set +const parseBoolean = (value: string | undefined): boolean | undefined => { + if (value === undefined || value === null || value === '') return undefined; + return value.toLowerCase() === 'true'; +}; + +// Helper to parse integer from env, returns undefined if not set +const parseInteger = (value: string | undefined): number | undefined => { + if (value === undefined || value === null || value === '') return undefined; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? undefined : parsed; +}; + +// Social provider configuration type +export interface SocialProviderConfig { + clientId: string; + clientSecret: string; + tenantId?: string; // For Microsoft/Azure AD + issuer?: string; // For self-hosted instances (e.g., GitLab) +} + +// Generic OAuth provider configuration type (for better-auth genericOAuth plugin) +export interface GenericOAuthProviderConfig { + providerId: string; + discoveryUrl?: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + clientId: string; + clientSecret: string; + scopes?: string[]; + pkce?: boolean; +} + +// Build social providers config dynamically from env vars +const buildSocialProviders = () => { + const providers: Record = {}; + + // GitHub + if (process.env.HEMMELIG_AUTH_GITHUB_ID && process.env.HEMMELIG_AUTH_GITHUB_SECRET) { + providers.github = { + clientId: process.env.HEMMELIG_AUTH_GITHUB_ID, + clientSecret: process.env.HEMMELIG_AUTH_GITHUB_SECRET, + }; + } + + // Google + if (process.env.HEMMELIG_AUTH_GOOGLE_ID && process.env.HEMMELIG_AUTH_GOOGLE_SECRET) { + providers.google = { + clientId: process.env.HEMMELIG_AUTH_GOOGLE_ID, + clientSecret: process.env.HEMMELIG_AUTH_GOOGLE_SECRET, + }; + } + + // Microsoft (Azure AD) + if (process.env.HEMMELIG_AUTH_MICROSOFT_ID && process.env.HEMMELIG_AUTH_MICROSOFT_SECRET) { + providers.microsoft = { + clientId: process.env.HEMMELIG_AUTH_MICROSOFT_ID, + clientSecret: process.env.HEMMELIG_AUTH_MICROSOFT_SECRET, + tenantId: process.env.HEMMELIG_AUTH_MICROSOFT_TENANT_ID, + }; + } + + // Discord + if (process.env.HEMMELIG_AUTH_DISCORD_ID && process.env.HEMMELIG_AUTH_DISCORD_SECRET) { + providers.discord = { + clientId: process.env.HEMMELIG_AUTH_DISCORD_ID, + clientSecret: process.env.HEMMELIG_AUTH_DISCORD_SECRET, + }; + } + + // GitLab + if (process.env.HEMMELIG_AUTH_GITLAB_ID && process.env.HEMMELIG_AUTH_GITLAB_SECRET) { + providers.gitlab = { + clientId: process.env.HEMMELIG_AUTH_GITLAB_ID, + clientSecret: process.env.HEMMELIG_AUTH_GITLAB_SECRET, + issuer: process.env.HEMMELIG_AUTH_GITLAB_ISSUER, + }; + } + + // Apple + if (process.env.HEMMELIG_AUTH_APPLE_ID && process.env.HEMMELIG_AUTH_APPLE_SECRET) { + providers.apple = { + clientId: process.env.HEMMELIG_AUTH_APPLE_ID, + clientSecret: process.env.HEMMELIG_AUTH_APPLE_SECRET, + }; + } + + // Twitter/X + if (process.env.HEMMELIG_AUTH_TWITTER_ID && process.env.HEMMELIG_AUTH_TWITTER_SECRET) { + providers.twitter = { + clientId: process.env.HEMMELIG_AUTH_TWITTER_ID, + clientSecret: process.env.HEMMELIG_AUTH_TWITTER_SECRET, + }; + } + + return providers; +}; + +// Build generic OAuth providers from JSON env var +const buildGenericOAuthProviders = (): GenericOAuthProviderConfig[] => { + const genericOAuthEnv = process.env.HEMMELIG_AUTH_GENERIC_OAUTH; + + if (!genericOAuthEnv) { + return []; + } + + try { + const parsed = JSON.parse(genericOAuthEnv); + if (!Array.isArray(parsed)) { + console.error('HEMMELIG_AUTH_GENERIC_OAUTH must be a JSON array'); + return []; + } + + // Validate each provider config + return parsed.filter((provider: any) => { + if (!provider.providerId || !provider.clientId || !provider.clientSecret) { + console.error( + `Invalid generic OAuth provider config: missing required fields (providerId, clientId, or clientSecret)` + ); + return false; + } + + // Must have either discoveryUrl OR all three URLs (authorization, token, userInfo) + const hasDiscoveryUrl = !!provider.discoveryUrl; + const hasManualUrls = !!( + provider.authorizationUrl && + provider.tokenUrl && + provider.userInfoUrl + ); + + if (!hasDiscoveryUrl && !hasManualUrls) { + console.error( + `Invalid generic OAuth provider config for "${provider.providerId}": must provide either discoveryUrl OR all of (authorizationUrl, tokenUrl, userInfoUrl)` + ); + return false; + } + + return true; + }) as GenericOAuthProviderConfig[]; + } catch (error) { + console.error('Failed to parse HEMMELIG_AUTH_GENERIC_OAUTH:', error); + return []; + } +}; + +const socialProviders = buildSocialProviders(); +const genericOAuthProviders = buildGenericOAuthProviders(); + +// Managed mode: all settings are controlled via environment variables +const isManaged = parseBoolean(process.env.HEMMELIG_MANAGED) ?? false; + +// Managed mode settings (only used when HEMMELIG_MANAGED=true) +const managedSettings = isManaged + ? { + // General settings + instanceName: process.env.HEMMELIG_INSTANCE_NAME ?? '', + instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION ?? '', + instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO ?? '', + allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION) ?? true, + requireEmailVerification: + parseBoolean(process.env.HEMMELIG_REQUIRE_EMAIL_VERIFICATION) ?? false, + defaultSecretExpiration: + parseInteger(process.env.HEMMELIG_DEFAULT_SECRET_EXPIRATION) ?? 72, + maxSecretSize: parseInteger(process.env.HEMMELIG_MAX_SECRET_SIZE) ?? 1024, + importantMessage: process.env.HEMMELIG_IMPORTANT_MESSAGE ?? '', + + // Security settings + allowPasswordProtection: + parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION) ?? true, + allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION) ?? true, + enableRateLimiting: parseBoolean(process.env.HEMMELIG_ENABLE_RATE_LIMITING) ?? true, + rateLimitRequests: parseInteger(process.env.HEMMELIG_RATE_LIMIT_REQUESTS) ?? 100, + rateLimitWindow: parseInteger(process.env.HEMMELIG_RATE_LIMIT_WINDOW) ?? 60, + + // Organization settings + requireInviteCode: parseBoolean(process.env.HEMMELIG_REQUIRE_INVITE_CODE) ?? false, + allowedEmailDomains: process.env.HEMMELIG_ALLOWED_EMAIL_DOMAINS ?? '', + requireRegisteredUser: + parseBoolean(process.env.HEMMELIG_REQUIRE_REGISTERED_USER) ?? false, + disableEmailPasswordSignup: + parseBoolean(process.env.HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP) ?? false, + + // Webhook settings + webhookEnabled: parseBoolean(process.env.HEMMELIG_WEBHOOK_ENABLED) ?? false, + webhookUrl: process.env.HEMMELIG_WEBHOOK_URL ?? '', + webhookSecret: process.env.HEMMELIG_WEBHOOK_SECRET ?? '', + webhookOnView: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_VIEW) ?? true, + webhookOnBurn: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_BURN) ?? true, + + // Metrics settings + metricsEnabled: parseBoolean(process.env.HEMMELIG_METRICS_ENABLED) ?? false, + metricsSecret: process.env.HEMMELIG_METRICS_SECRET ?? '', + + // File upload settings + allowFileUploads: parseBoolean(process.env.HEMMELIG_ALLOW_FILE_UPLOADS) ?? true, + } + : null; + +const config = { + server: { + port: Number(process.env.HEMMELIG_PORT) || 3000, + }, + trustedOrigins: [ + ...(!isProduction ? ['http://localhost:5173'] : []), + process.env.HEMMELIG_TRUSTED_ORIGIN || '', + ].filter(Boolean), + general: { + instanceName: process.env.HEMMELIG_INSTANCE_NAME, + instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION, + instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO, + allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION), + }, + security: { + allowPasswordProtection: parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION), + allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION), + }, + analytics: { + enabled: parseBoolean(process.env.HEMMELIG_ANALYTICS_ENABLED) ?? true, + hmacSecret: + process.env.HEMMELIG_ANALYTICS_HMAC_SECRET || 'default-analytics-secret-change-me', + }, + socialProviders, +}; + +if (!process.env.HEMMELIG_ANALYTICS_HMAC_SECRET && config.analytics.enabled) { + console.warn( + 'WARNING: HEMMELIG_ANALYTICS_HMAC_SECRET is not set. Analytics visitor IDs are generated ' + + 'with a default secret, making them predictable. Set a random secret for production use.' + ); +} + +/** + * A type-safe utility to get a value from the configuration. + * Its return type is inferred from the type of the default value. + * @param path The dot-notation path to the config value (e.g., 'server.port'). + * @param defaultValue A default value to return if the path is not found. + * @returns The found configuration value or the default value. + */ +function get(path: string, defaultValue?: T): T { + return dlv(config, path, defaultValue) as T; +} + +// Export the get function and social providers helper +export default { + get, + getSocialProviders: () => config.socialProviders, + getGenericOAuthProviders: () => genericOAuthProviders, + isManaged: () => isManaged, + getManagedSettings: () => managedSettings, +}; diff --git a/api/jobs/expired.ts b/api/jobs/expired.ts new file mode 100644 index 0000000..ac9d1f8 --- /dev/null +++ b/api/jobs/expired.ts @@ -0,0 +1,67 @@ +import { unlink } from 'fs/promises'; +import prisma from '../lib/db'; + +export const deleteExpiredSecrets = async () => { + try { + const now = new Date(); + await prisma.secrets.deleteMany({ + where: { + OR: [ + { + expiresAt: { + lte: now, + }, + }, + { + views: 0, + }, + ], + }, + }); + } catch (error) { + console.error('Error deleting expired secrets:', error); + } +}; + +export const deleteOrphanedFiles = async () => { + try { + // Find files that are not associated with any secret + const orphanedFiles = await prisma.file.findMany({ + where: { + secrets: { + none: {}, + }, + }, + }); + + if (orphanedFiles.length === 0) { + return; + } + + // Delete files from disk in parallel for better performance + const deleteResults = await Promise.allSettled( + orphanedFiles.map((file) => unlink(file.path)) + ); + + // Log any failures (file may already be deleted or inaccessible) + deleteResults.forEach((result, index) => { + if (result.status === 'rejected') { + console.error( + `Failed to delete file from disk: ${orphanedFiles[index].path}`, + result.reason + ); + } + }); + + // Delete orphaned file records from database + await prisma.file.deleteMany({ + where: { + id: { + in: orphanedFiles.map((f) => f.id), + }, + }, + }); + } catch (error) { + console.error('Error deleting orphaned files:', error); + } +}; diff --git a/api/jobs/index.ts b/api/jobs/index.ts new file mode 100644 index 0000000..a268605 --- /dev/null +++ b/api/jobs/index.ts @@ -0,0 +1,14 @@ +import { Cron } from 'croner'; +import { deleteExpiredSecrets, deleteOrphanedFiles } from './expired'; + +// https://crontab.guru +export default function startJobs() { + // This function can be used to start any other jobs in the future + console.log('Job scheduler initialized.'); + + // Running every minute + new Cron('* * * * *', async () => { + await deleteExpiredSecrets(); + await deleteOrphanedFiles(); + }); +} diff --git a/api/lib/analytics.ts b/api/lib/analytics.ts new file mode 100644 index 0000000..fa21296 --- /dev/null +++ b/api/lib/analytics.ts @@ -0,0 +1,62 @@ +import { createHmac } from 'crypto'; +import config from '../config'; + +const analyticsConfig = config.get('analytics') as { enabled: boolean; hmacSecret: string }; + +/** + * Creates a unique, anonymous visitor ID using HMAC-SHA256. + * This ensures privacy by never storing the raw IP address. + */ +export function createVisitorId(ip: string, userAgent: string): string { + return createHmac('sha256', analyticsConfig.hmacSecret) + .update(ip + userAgent) + .digest('hex'); +} + +/** + * Validates that a path is safe for analytics tracking. + * Prevents injection of malicious paths. + */ +export function isValidAnalyticsPath(path: string): boolean { + const pathRegex = /^\/[a-zA-Z0-9\-?=&/#]*$/; + return pathRegex.test(path) && path.length <= 255; +} + +/** + * Checks if analytics tracking is enabled. + */ +export function isAnalyticsEnabled(): boolean { + return analyticsConfig.enabled; +} + +/** + * Calculates the start date for a given time range. + * @param timeRange - Time range string (7d, 14d, 30d) + * @returns Start date for the query + */ +export function getStartDateForTimeRange(timeRange: '7d' | '14d' | '30d'): Date { + const now = new Date(); + const startDate = new Date(); + + switch (timeRange) { + case '7d': + startDate.setDate(now.getDate() - 7); + break; + case '14d': + startDate.setDate(now.getDate() - 14); + break; + case '30d': + startDate.setDate(now.getDate() - 30); + break; + } + + return startDate; +} + +/** + * Calculates percentage with fixed decimal places, returning 0 if total is 0. + */ +export function calculatePercentage(value: number, total: number, decimals = 2): number { + if (total === 0) return 0; + return parseFloat(((value / total) * 100).toFixed(decimals)); +} diff --git a/api/lib/constants.ts b/api/lib/constants.ts new file mode 100644 index 0000000..0a25297 --- /dev/null +++ b/api/lib/constants.ts @@ -0,0 +1,85 @@ +/** + * Time constants in milliseconds + */ +export const TIME = { + /** One second in milliseconds */ + SECOND_MS: 1000, + /** One minute in milliseconds */ + MINUTE_MS: 60 * 1000, + /** One hour in milliseconds */ + HOUR_MS: 60 * 60 * 1000, + /** One day in milliseconds */ + DAY_MS: 24 * 60 * 60 * 1000, +} as const; + +/** + * Secret-related constants + */ +export const SECRET = { + /** Grace period for file downloads after last view (5 minutes) */ + FILE_DOWNLOAD_GRACE_PERIOD_MS: 5 * TIME.MINUTE_MS, +} as const; + +/** + * File upload constants + */ +export const FILE = { + /** Default max file size in KB (10MB) */ + DEFAULT_MAX_SIZE_KB: 10240, +} as const; + +/** + * Valid secret expiration times in seconds + */ +export const EXPIRATION_TIMES_SECONDS = [ + 2419200, // 28 days + 1209600, // 14 days + 604800, // 7 days + 259200, // 3 days + 86400, // 1 day + 43200, // 12 hours + 14400, // 4 hours + 3600, // 1 hour + 1800, // 30 minutes + 300, // 5 minutes +] as const; + +/** + * Instance settings fields - public (safe for all users) + */ +export const PUBLIC_SETTINGS_FIELDS = { + instanceName: true, + instanceDescription: true, + instanceLogo: true, + allowRegistration: true, + defaultSecretExpiration: true, + maxSecretSize: true, + allowPasswordProtection: true, + allowIpRestriction: true, + allowFileUploads: true, + requireRegisteredUser: true, + importantMessage: true, + disableEmailPasswordSignup: true, +} as const; + +/** + * Instance settings fields - admin only (all fields) + */ +export const ADMIN_SETTINGS_FIELDS = { + ...PUBLIC_SETTINGS_FIELDS, + requireEmailVerification: true, + enableRateLimiting: true, + rateLimitRequests: true, + rateLimitWindow: true, + requireInviteCode: true, + allowedEmailDomains: true, + disableEmailPasswordSignup: true, + webhookEnabled: true, + webhookUrl: true, + webhookSecret: true, + webhookOnView: true, + webhookOnBurn: true, + importantMessage: true, + metricsEnabled: true, + metricsSecret: true, +} as const; diff --git a/api/lib/db.ts b/api/lib/db.ts new file mode 100644 index 0000000..3ed7a50 --- /dev/null +++ b/api/lib/db.ts @@ -0,0 +1,19 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { PrismaClient } from '../../prisma/generated/prisma/client.js'; + +const prismaClientSingleton = () => { + const adapter = new PrismaBetterSqlite3({ + url: process.env.DATABASE_URL || 'file:./database/hemmelig.db', + }); + return new PrismaClient({ adapter }); +}; + +declare global { + var prisma: undefined | ReturnType; +} + +const db = globalThis.prisma ?? prismaClientSingleton(); + +export default db; + +if (process.env.NODE_ENV !== 'production') globalThis.prisma = db; diff --git a/api/lib/files.ts b/api/lib/files.ts new file mode 100644 index 0000000..8214369 --- /dev/null +++ b/api/lib/files.ts @@ -0,0 +1,77 @@ +import { mkdir } from 'fs/promises'; +import { basename, join, resolve } from 'path'; +import { FILE } from './constants'; +import settingsCache from './settings'; + +/** Upload directory path */ +export const UPLOAD_DIR = resolve(process.cwd(), 'uploads'); + +/** + * Sanitizes a filename by removing path traversal sequences and directory separators. + * Returns only the base filename to prevent directory escape attacks. + */ +export function sanitizeFilename(filename: string): string { + // Get only the base filename, stripping any directory components + const base = basename(filename); + // Remove any remaining null bytes or other dangerous characters + return base.replace(/[\x00-\x1f]/g, ''); +} + +/** + * Validates that a file path is safely within the upload directory. + * Prevents path traversal attacks by checking the resolved absolute path. + */ +export function isPathSafe(filePath: string): boolean { + const resolvedPath = resolve(filePath); + return resolvedPath.startsWith(UPLOAD_DIR + '/') || resolvedPath === UPLOAD_DIR; +} + +/** + * Gets max file size from instance settings (in KB), converted to bytes. + * Defaults to 10MB if not configured. + */ +export function getMaxFileSize(): number { + const settings = settingsCache.get('instanceSettings'); + const maxSecretSizeKB = settings?.maxSecretSize ?? FILE.DEFAULT_MAX_SIZE_KB; + return maxSecretSizeKB * 1024; // Convert KB to bytes +} + +/** + * Ensures the upload directory exists, creating it if necessary. + */ +export async function ensureUploadDir(): Promise { + try { + await mkdir(UPLOAD_DIR, { recursive: true }); + } catch (error) { + console.error('Failed to create upload directory:', error); + } +} + +/** + * Generates a safe file path within the upload directory. + * @param id - Unique identifier for the file + * @param originalFilename - Original filename to sanitize + * @returns Object with sanitized filename and full path, or null if invalid + */ +export function generateSafeFilePath( + id: string, + originalFilename: string +): { filename: string; path: string } | null { + const safeFilename = sanitizeFilename(originalFilename); + if (!safeFilename) { + return null; + } + + const filename = `${id}-${safeFilename}`; + const path = join(UPLOAD_DIR, filename); + + // Verify path is safe + if (!isPathSafe(path)) { + return null; + } + + return { filename, path }; +} + +// Initialize upload directory on module load +ensureUploadDir(); diff --git a/api/lib/password.ts b/api/lib/password.ts new file mode 100644 index 0000000..cc5b050 --- /dev/null +++ b/api/lib/password.ts @@ -0,0 +1,49 @@ +import * as argon2 from 'argon2'; + +/** + * Hashes a password using Argon2id. + * Uses Bun.password if available, otherwise falls back to argon2 npm package. + * @param password The plain-text password to hash. + * @returns A promise that resolves to the hashed password. + * @throws Will throw an error if hashing fails. + */ +export async function hash(password: string): Promise { + try { + // Try Bun's native password hashing first (uses Argon2) + if (typeof Bun !== 'undefined' && Bun.password) { + return await Bun.password.hash(password); + } + + // Fallback to argon2 npm package (Argon2id) + return await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + } catch (error) { + console.error('Error during password hashing:', error); + throw new Error('Error hashing the password.'); + } +} + +/** + * Compares a plain-text password with a hash. + * @param password The plain-text password to compare. + * @param storedHash The hash to compare against. + * @returns A promise that resolves to true if the password matches the hash, otherwise false. + */ +export async function compare(password: string, storedHash: string): Promise { + try { + // Try Bun's native password verification first + if (typeof Bun !== 'undefined' && Bun.password) { + return await Bun.password.verify(password, storedHash); + } + + // Fallback to argon2 npm package + return await argon2.verify(storedHash, password); + } catch (error) { + console.error('Error during password comparison:', error); + return false; + } +} diff --git a/api/lib/settings.ts b/api/lib/settings.ts new file mode 100644 index 0000000..b011d4d --- /dev/null +++ b/api/lib/settings.ts @@ -0,0 +1,40 @@ +import prisma from './db'; + +const settingsCache = new Map(); + +/** + * Gets instance settings, fetching from database if not cached. + * Use this utility to avoid duplicating the cache-check pattern. + */ +export async function getInstanceSettings() { + let cachedSettings = settingsCache.get('instanceSettings'); + if (!cachedSettings) { + try { + cachedSettings = await prisma.instanceSettings.findFirst(); + if (cachedSettings) { + settingsCache.set('instanceSettings', cachedSettings); + } + } catch { + // Table may not exist yet (fresh database) + return null; + } + } + return cachedSettings; +} + +/** + * Updates the cached instance settings. + * Call this after modifying settings in the database. + */ +export function setCachedInstanceSettings(settings: unknown) { + settingsCache.set('instanceSettings', settings); +} + +/** + * Gets the raw settings cache (for direct access when needed). + */ +export function getSettingsCache() { + return settingsCache; +} + +export default settingsCache; diff --git a/api/lib/utils.ts b/api/lib/utils.ts new file mode 100644 index 0000000..3f7567e --- /dev/null +++ b/api/lib/utils.ts @@ -0,0 +1,130 @@ +import dns from 'dns/promises'; +import { type Context } from 'hono'; +import { isIP } from 'is-ip'; + +/** + * Handle not found error from Prisma + * @param error Error from Prisma operation + * @param c Hono context + * @returns JSON error response + */ +export const handleNotFound = (error: Error & { code?: string }, c: Context) => { + // Handle record not found error (Prisma P2025) + if (error?.code === 'P2025') { + return c.json({ error: 'Not found' }, 404); + } + + // Handle other errors + return c.json( + { + error: 'Failed to process the operation', + }, + 500 + ); +}; + +/** + * Get client IP from request headers + * @param c Hono context + * @returns Client IP address + */ +export const getClientIp = (c: Context): string => { + const forwardedFor = c.req.header('x-forwarded-for'); + if (forwardedFor) { + return forwardedFor.split(',')[0].trim(); + } + return ( + c.req.header('x-real-ip') || + c.req.header('cf-connecting-ip') || + c.req.header('client-ip') || + c.req.header('x-client-ip') || + c.req.header('x-cluster-client-ip') || + c.req.header('forwarded-for') || + c.req.header('forwarded') || + c.req.header('via') || + '127.0.0.1' + ); +}; + +// Patterns for private/internal IP addresses +const privateIpPatterns = [ + // Localhost variants + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^0\.0\.0\.0$/, + // Private IPv4 ranges + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^192\.168\.\d{1,3}\.\d{1,3}$/, + /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, + // Link-local IPv4 + /^169\.254\.\d{1,3}\.\d{1,3}$/, + // IPv6 localhost + /^::1$/, + /^\[::1\]$/, + // IPv6 link-local + /^fe80:/i, + // IPv6 private (unique local addresses) + /^fc00:/i, + /^fd[0-9a-f]{2}:/i, +]; + +// Patterns for special domains that should always be blocked +const blockedHostnamePatterns = [ + /^localhost$/, + /\.local$/, + /\.internal$/, + /\.localhost$/, + /\.localdomain$/, +]; + +/** + * Check if an IP address is private/internal + * @param ip IP address to check + * @returns true if IP is private/internal + */ +const isPrivateIp = (ip: string): boolean => { + return privateIpPatterns.some((pattern) => pattern.test(ip)); +}; + +/** + * Check if a URL points to a private/internal address (SSRF protection) + * Resolves DNS to check actual IP addresses, preventing DNS rebinding attacks. + * @param url URL string to validate + * @returns Promise if URL is safe (not internal), Promise if it's a private/internal address + */ +export const isPublicUrl = async (url: string): Promise => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + + // Block special domain patterns (e.g., .local, .localhost) + if (blockedHostnamePatterns.some((pattern) => pattern.test(hostname))) { + return false; + } + + // If hostname is already an IP address, check it directly + if (isIP(hostname)) { + return !isPrivateIp(hostname); + } + + // Resolve DNS to get actual IP addresses + let addresses: string[] = []; + try { + const ipv4Addresses = await dns.resolve4(hostname).catch(() => []); + const ipv6Addresses = await dns.resolve6(hostname).catch(() => []); + addresses = [...ipv4Addresses, ...ipv6Addresses]; + } catch { + // DNS resolution failed - reject for safety + return false; + } + + // Require at least one resolvable address + if (addresses.length === 0) { + return false; + } + + // Check all resolved IPs - reject if ANY resolve to private addresses + return !addresses.some((ip) => isPrivateIp(ip)); + } catch { + return false; + } +}; diff --git a/api/lib/webhook.ts b/api/lib/webhook.ts new file mode 100644 index 0000000..0790cd3 --- /dev/null +++ b/api/lib/webhook.ts @@ -0,0 +1,106 @@ +import { createHmac } from 'crypto'; +import config from '../config'; +import { getInstanceSettings } from './settings'; + +export type WebhookEvent = 'secret.viewed' | 'secret.burned' | 'apikey.created'; + +interface SecretWebhookData { + secretId: string; + hasPassword: boolean; + hasIpRestriction: boolean; + viewsRemaining?: number; +} + +interface ApiKeyWebhookData { + apiKeyId: string; + name: string; + expiresAt: string | null; + userId: string; +} + +interface WebhookPayload { + event: WebhookEvent; + timestamp: string; + data: SecretWebhookData | ApiKeyWebhookData; +} + +function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +async function sendWithRetry( + url: string, + headers: Record, + body: string +): Promise { + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(5000), + redirect: 'error', + }); + + if (response.ok) return; + + if (response.status >= 400 && response.status < 500) { + console.error(`Webhook delivery failed: ${response.status}`); + return; + } + } catch (error) { + if (attempt === maxRetries - 1) { + console.error('Webhook delivery failed after retries:', error); + return; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } +} + +export function sendWebhook(event: WebhookEvent, data: WebhookPayload['data']): void { + (async () => { + try { + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + + if (!settings?.webhookEnabled || !settings.webhookUrl) { + return; + } + + if (event === 'secret.viewed' && !settings.webhookOnView) { + return; + } + if (event === 'secret.burned' && !settings.webhookOnBurn) { + return; + } + + const payload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + data, + }; + + const payloadString = JSON.stringify(payload); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Paste-Event': event, + 'User-Agent': 'Paste-ES-Webhook/1.0', + }; + + if (settings.webhookSecret) { + const signature = signPayload(payloadString, settings.webhookSecret); + headers['X-Paste-Signature'] = `sha256=${signature}`; + } + + await sendWithRetry(settings.webhookUrl, headers, payloadString); + } catch (error) { + console.error('Error preparing webhook:', error); + } + })(); +} diff --git a/api/middlewares/auth.ts b/api/middlewares/auth.ts new file mode 100644 index 0000000..9a7e697 --- /dev/null +++ b/api/middlewares/auth.ts @@ -0,0 +1,91 @@ +import { createHash } from 'crypto'; +import { createMiddleware } from 'hono/factory'; +import { auth } from '../auth'; +import prisma from '../lib/db'; + +type Env = { + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + }; +}; + +export const authMiddleware = createMiddleware(async (c, next) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); +}); + +export const checkAdmin = createMiddleware(async (c, next) => { + const sessionUser = c.get('user'); + if (!sessionUser) { + return c.json({ error: 'Forbidden' }, 403); + } + + const user = await prisma.user.findUnique({ + where: { id: sessionUser.id }, + select: { role: true }, + }); + + if (!user || user.role !== 'admin') { + return c.json({ error: 'Forbidden' }, 403); + } + await next(); +}); + +// Middleware that accepts either session auth OR API key auth +export const apiKeyOrAuthMiddleware = createMiddleware(async (c, next) => { + // First check if user is already authenticated via session + const sessionUser = c.get('user'); + if (sessionUser) { + return next(); + } + + // Check for API key in Authorization header + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const apiKey = authHeader.substring(7); + if (!apiKey.startsWith('hemmelig_')) { + return c.json({ error: 'Invalid API key format' }, 401); + } + + try { + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const apiKeyRecord = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (!apiKeyRecord) { + return c.json({ error: 'Invalid API key' }, 401); + } + + // Check if key is expired + if (apiKeyRecord.expiresAt && new Date() > apiKeyRecord.expiresAt) { + return c.json({ error: 'API key has expired' }, 401); + } + + // Update last used timestamp (fire and forget) + prisma.apiKey + .update({ + where: { id: apiKeyRecord.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + // Set user from API key + c.set('user', apiKeyRecord.user as typeof auth.$Infer.Session.user); + c.set('session', null); + + return next(); + } catch (error) { + console.error('API key auth error:', error); + return c.json({ error: 'Authentication failed' }, 401); + } +}); diff --git a/api/middlewares/ip-restriction.ts b/api/middlewares/ip-restriction.ts new file mode 100644 index 0000000..26afe5c --- /dev/null +++ b/api/middlewares/ip-restriction.ts @@ -0,0 +1,33 @@ +import { Context, Next } from 'hono'; +import ipRangeCheck from 'ip-range-check'; +import prisma from '../lib/db'; +import { getClientIp } from '../lib/utils'; + +export const ipRestriction = async (c: Context, next: Next) => { + const { id } = c.req.param(); + + const item = await prisma.secrets.findUnique({ + where: { id }, + select: { + ipRange: true, + }, + }); + + // If no restriction is configured, move on + if (!item?.ipRange) { + return next(); + } + + const ip = getClientIp(c); + + if (!ip) { + return c.json({ error: 'Could not identify client IP' }, 400); + } + + // The core logic is now a single, clean line + if (!ipRangeCheck(ip, item.ipRange)) { + return c.json({ error: 'Access restricted by IP' }, 403); + } + + await next(); +}; diff --git a/api/middlewares/ratelimit.ts b/api/middlewares/ratelimit.ts new file mode 100644 index 0000000..9c4fce7 --- /dev/null +++ b/api/middlewares/ratelimit.ts @@ -0,0 +1,32 @@ +import { Context, Next } from 'hono'; +import { rateLimiter } from 'hono-rate-limiter'; +import settingsCache from '../lib/settings'; +import { getClientIp } from '../lib/utils'; + +let rateLimitInstance: ReturnType | null = null; + +const ratelimit = async (c: Context, next: Next) => { + const instanceSettings = settingsCache.get('instanceSettings'); + + if (instanceSettings?.enableRateLimiting) { + if (rateLimitInstance === null) { + rateLimitInstance = rateLimiter({ + windowMs: instanceSettings.rateLimitWindow * 1000, // Convert seconds to milliseconds + limit: instanceSettings.rateLimitRequests, + standardHeaders: true, + keyGenerator: (c) => getClientIp(c) || 'anonymous', + }); + } + + return rateLimitInstance(c, next); + } + + // If rate limiting is disabled, ensure the limiter is cleared + if (rateLimitInstance !== null) { + rateLimitInstance = null; + } + + await next(); +}; + +export default ratelimit; diff --git a/api/openapi.ts b/api/openapi.ts new file mode 100644 index 0000000..802ce1d --- /dev/null +++ b/api/openapi.ts @@ -0,0 +1,1568 @@ +import { swaggerUI } from '@hono/swagger-ui'; +import { Hono } from 'hono'; + +const openapi = new Hono(); + +const spec = { + openapi: '3.0.3', + info: { + title: 'paste.es API', + description: + 'API for paste.es - a secure secret sharing application. All encryption/decryption happens client-side.', + version: '1.0.0', + contact: { + name: 'CloudHost.es', + url: 'https://cloudhost.es', + }, + }, + servers: [ + { + url: '/api', + description: 'API server', + }, + ], + tags: [ + { name: 'Secrets', description: 'Secret management endpoints' }, + { name: 'Secret Requests', description: 'Request secrets from others' }, + { name: 'Files', description: 'File upload/download endpoints' }, + { name: 'Account', description: 'User account management' }, + { name: 'API Keys', description: 'API key management for programmatic access' }, + { name: 'Instance', description: 'Instance settings' }, + { name: 'Analytics', description: 'Analytics endpoints' }, + { name: 'Invites', description: 'Invite code management' }, + { name: 'Users', description: 'User management (admin)' }, + { name: 'Setup', description: 'Initial setup' }, + { name: 'Health', description: 'Health check' }, + { name: 'Config', description: 'Configuration endpoints' }, + { name: 'Metrics', description: 'Prometheus metrics endpoint' }, + ], + paths: { + '/healthz': { + get: { + tags: ['Health'], + summary: 'Legacy liveness check', + description: + 'Simple liveness check. Kept for backwards compatibility. Consider using /health/live instead.', + responses: { + '200': { + description: 'Service is running', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/health/live': { + get: { + tags: ['Health'], + summary: 'Liveness probe', + description: + 'Simple check to verify the process is running. Use for Kubernetes liveness probes.', + responses: { + '200': { + description: 'Process is alive', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/health/ready': { + get: { + tags: ['Health'], + summary: 'Readiness probe', + description: + 'Comprehensive health check verifying database connectivity, file storage, and memory usage. Use for Kubernetes readiness probes.', + responses: { + '200': { + description: 'Service is ready to accept traffic', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthCheckResponse' }, + }, + }, + }, + '503': { + description: 'Service is not ready - one or more checks failed', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthCheckResponse' }, + }, + }, + }, + }, + }, + }, + '/config/social-providers': { + get: { + tags: ['Config'], + summary: 'Get enabled social authentication providers', + responses: { + '200': { + description: 'List of enabled providers', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + providers: { type: 'array', items: { type: 'string' } }, + callbackBaseUrl: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/secrets': { + get: { + tags: ['Secrets'], + summary: 'List user secrets', + description: 'Get paginated list of secrets created by the authenticated user', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + ], + responses: { + '200': { + description: 'List of secrets', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/SecretListItem' }, + }, + meta: { $ref: '#/components/schemas/PaginationMeta' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['Secrets'], + summary: 'Create a new secret', + description: + 'Create a new encrypted secret. The secret content should be encrypted client-side before sending.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateSecretRequest' }, + }, + }, + }, + responses: { + '201': { + description: 'Secret created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '409': { description: 'Conflict - could not create secret' }, + }, + }, + }, + '/secrets/{id}': { + post: { + tags: ['Secrets'], + summary: 'Get a secret', + description: + 'Retrieve an encrypted secret by ID. Atomically consumes a view and burns the secret if burnable and last view. Password required if secret is password-protected.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { password: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Secret data', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Secret' }, + }, + }, + }, + '401': { description: 'Invalid password' }, + '404': { description: 'Secret not found' }, + }, + }, + delete: { + tags: ['Secrets'], + summary: 'Delete a secret', + description: 'Manually burn/delete a secret', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Secret deleted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + '404': { description: 'Secret not found' }, + }, + }, + }, + '/secrets/{id}/check': { + get: { + tags: ['Secrets'], + summary: 'Check secret status', + description: 'Check if a secret exists and whether it requires a password', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Secret status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + views: { type: 'integer' }, + title: { type: 'string', nullable: true }, + isPasswordProtected: { type: 'boolean' }, + }, + }, + }, + }, + }, + '404': { description: 'Secret not found' }, + }, + }, + }, + '/secret-requests': { + get: { + tags: ['Secret Requests'], + summary: 'List your secret requests', + description: + 'Get paginated list of secret requests created by the authenticated user', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + { + name: 'status', + in: 'query', + schema: { + type: 'string', + enum: ['all', 'pending', 'fulfilled', 'expired', 'cancelled'], + }, + }, + ], + responses: { + '200': { + description: 'List of secret requests', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/SecretRequest' }, + }, + meta: { $ref: '#/components/schemas/PaginationMeta' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['Secret Requests'], + summary: 'Create a secret request', + description: + 'Create a new secret request. Returns a link to share with the person who will submit the secret.', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateSecretRequestBody' }, + }, + }, + }, + responses: { + '201': { + description: 'Secret request created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + creatorLink: { + type: 'string', + description: 'Link to share with the secret creator', + }, + webhookSecret: { + type: 'string', + nullable: true, + description: 'Webhook signing secret (only shown once)', + }, + expiresAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/secret-requests/{id}': { + get: { + tags: ['Secret Requests'], + summary: 'Get secret request details', + description: 'Get details of a specific secret request (owner only)', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + '200': { + description: 'Secret request details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SecretRequestDetail' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + '404': { description: 'Secret request not found' }, + }, + }, + delete: { + tags: ['Secret Requests'], + summary: 'Cancel a secret request', + description: 'Cancel a pending secret request (owner only)', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + '200': { + description: 'Secret request cancelled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + '400': { description: 'Can only cancel pending requests' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + '404': { description: 'Secret request not found' }, + }, + }, + }, + '/secret-requests/{id}/info': { + get: { + tags: ['Secret Requests'], + summary: 'Get request info (public)', + description: + 'Get basic info about a secret request. Requires the token from the request link.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'token', + in: 'query', + required: true, + schema: { type: 'string', minLength: 64, maxLength: 64 }, + description: 'Request token from the creator link', + }, + ], + responses: { + '200': { + description: 'Request info', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + '404': { description: 'Invalid or expired request' }, + '410': { description: 'Request already fulfilled or expired' }, + }, + }, + }, + '/secret-requests/{id}/submit': { + post: { + tags: ['Secret Requests'], + summary: 'Submit a secret (public)', + description: + 'Submit an encrypted secret for a request. The secret is encrypted client-side before submission.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'token', + in: 'query', + required: true, + schema: { type: 'string', minLength: 64, maxLength: 64 }, + description: 'Request token from the creator link', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['secret', 'salt'], + properties: { + secret: { + type: 'object', + description: 'Encrypted secret as Uint8Array object', + }, + title: { + type: 'object', + nullable: true, + description: 'Encrypted title as Uint8Array object', + }, + salt: { + type: 'string', + minLength: 16, + maxLength: 64, + description: 'Salt used for encryption', + }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Secret created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + secretId: { + type: 'string', + description: + 'ID of the created secret. Client constructs full URL with decryption key.', + }, + }, + }, + }, + }, + }, + '404': { description: 'Invalid request' }, + '410': { description: 'Request already fulfilled or expired' }, + }, + }, + }, + '/files': { + post: { + tags: ['Files'], + summary: 'Upload a file', + description: 'Upload an encrypted file to attach to a secret', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'File uploaded', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + '400': { description: 'Invalid file' }, + '413': { description: 'File too large' }, + }, + }, + }, + '/files/{id}': { + get: { + tags: ['Files'], + summary: 'Download a file', + description: 'Download an encrypted file by ID', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'File content', + content: { 'application/octet-stream': {} }, + }, + '404': { description: 'File not found' }, + }, + }, + }, + '/account': { + get: { + tags: ['Account'], + summary: 'Get account info', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Account information', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + put: { + tags: ['Account'], + summary: 'Update account info', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Account updated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '409': { description: 'Username already taken' }, + }, + }, + delete: { + tags: ['Account'], + summary: 'Delete account', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Account deleted' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/account/password': { + put: { + tags: ['Account'], + summary: 'Update password', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['currentPassword', 'newPassword'], + properties: { + currentPassword: { type: 'string' }, + newPassword: { type: 'string', minLength: 8 }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Password updated' }, + '400': { description: 'Invalid current password' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/api-keys': { + get: { + tags: ['API Keys'], + summary: 'List API keys', + description: 'Get all API keys for the authenticated user', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of API keys', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/ApiKey' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['API Keys'], + summary: 'Create API key', + description: 'Create a new API key. The full key is only shown once upon creation.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + expiresInDays: { type: 'integer', minimum: 1, maximum: 365 }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'API key created', + content: { + 'application/json': { + schema: { + allOf: [ + { $ref: '#/components/schemas/ApiKey' }, + { + type: 'object', + properties: { + key: { + type: 'string', + description: + 'The full API key (only shown once)', + }, + }, + }, + ], + }, + }, + }, + }, + '400': { description: 'Maximum API key limit reached (5)' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/api-keys/{id}': { + delete: { + tags: ['API Keys'], + summary: 'Delete API key', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { description: 'API key deleted' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '404': { description: 'API key not found' }, + }, + }, + }, + '/instance/settings/public': { + get: { + tags: ['Instance'], + summary: 'Get public instance settings', + responses: { + '200': { + description: 'Public settings', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PublicInstanceSettings' }, + }, + }, + }, + }, + }, + }, + '/instance/settings': { + get: { + tags: ['Instance'], + summary: 'Get all instance settings (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Instance settings', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InstanceSettings' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + put: { + tags: ['Instance'], + summary: 'Update instance settings (admin)', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InstanceSettings' }, + }, + }, + }, + responses: { + '200': { description: 'Settings updated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics': { + get: { + tags: ['Analytics'], + summary: 'Get secret analytics (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'timeRange', + in: 'query', + schema: { + type: 'string', + enum: ['7d', '30d', '90d', '1y'], + default: '30d', + }, + }, + ], + responses: { + '200': { description: 'Analytics data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/track': { + post: { + tags: ['Analytics'], + summary: 'Track page visit', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['path'], + properties: { path: { type: 'string', maxLength: 255 } }, + }, + }, + }, + }, + responses: { + '201': { description: 'Tracked' }, + '403': { description: 'Analytics disabled or bot detected' }, + }, + }, + }, + '/analytics/visitors': { + get: { + tags: ['Analytics'], + summary: 'Get visitor analytics (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Visitor data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/visitors/unique': { + get: { + tags: ['Analytics'], + summary: 'Get unique visitor analytics (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Unique visitor data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/visitors/daily': { + get: { + tags: ['Analytics'], + summary: 'Get daily visitor stats (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Daily visitor statistics' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites': { + get: { + tags: ['Invites'], + summary: 'List invite codes (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of invite codes', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/InviteCode' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + post: { + tags: ['Invites'], + summary: 'Create invite code (admin)', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + maxUses: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 1, + }, + expiresInDays: { type: 'integer', minimum: 1, maximum: 365 }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Invite code created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InviteCode' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites/{id}': { + delete: { + tags: ['Invites'], + summary: 'Deactivate invite code (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { description: 'Invite code deactivated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites/public/validate': { + post: { + tags: ['Invites'], + summary: 'Validate invite code', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code'], + properties: { code: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Validation result', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { valid: { type: 'boolean' } }, + }, + }, + }, + }, + '400': { description: 'Invalid invite code' }, + }, + }, + }, + '/invites/public/use': { + post: { + tags: ['Invites'], + summary: 'Use invite code', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code', 'userId'], + properties: { + code: { type: 'string' }, + userId: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Invite code used' }, + '400': { description: 'Invalid invite code' }, + }, + }, + }, + '/user': { + get: { + tags: ['Users'], + summary: 'List users (admin)', + description: 'Get paginated list of users with optional search', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'pageSize', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + { + name: 'search', + in: 'query', + schema: { type: 'string', maxLength: 100 }, + description: 'Search by username, email, or name', + }, + ], + responses: { + '200': { + description: 'Paginated list of users', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + users: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + total: { type: 'integer' }, + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/user/{id}': { + put: { + tags: ['Users'], + summary: 'Update user (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'User updated', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/setup/status': { + get: { + tags: ['Setup'], + summary: 'Check if setup is needed', + responses: { + '200': { + description: 'Setup status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { needsSetup: { type: 'boolean' } }, + }, + }, + }, + }, + }, + }, + }, + '/setup/complete': { + post: { + tags: ['Setup'], + summary: 'Complete initial setup', + description: 'Create the first admin user. Only works when no users exist.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['email', 'password', 'username', 'name'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + username: { type: 'string', minLength: 3, maxLength: 32 }, + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Setup completed' }, + '403': { description: 'Setup already completed' }, + }, + }, + }, + '/metrics': { + get: { + tags: ['Metrics'], + summary: 'Get Prometheus metrics', + description: + 'Returns metrics in Prometheus exposition format. Requires metrics to be enabled in instance settings. If a metrics secret is configured, Bearer token authentication is required.', + security: [{ metricsAuth: [] }], + responses: { + '200': { + description: 'Prometheus metrics', + content: { + 'text/plain': { + schema: { + type: 'string', + example: + '# HELP hemmelig_secrets_active_count Current number of active (unexpired) secrets\n# TYPE hemmelig_secrets_active_count gauge\nhemmelig_secrets_active_count 42', + }, + }, + }, + }, + '401': { + description: 'Unauthorized - invalid or missing Bearer token', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + '404': { + description: 'Metrics endpoint is disabled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'better-auth.session_token', + description: 'Session cookie set after authentication via /auth endpoints', + }, + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key authentication. Use your API key as the bearer token.', + }, + metricsAuth: { + type: 'http', + scheme: 'bearer', + description: + 'Metrics endpoint authentication. Use the configured metrics secret as the bearer token.', + }, + }, + schemas: { + SecretRequest: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + status: { + type: 'string', + enum: ['pending', 'fulfilled', 'expired', 'cancelled'], + }, + maxViews: { type: 'integer' }, + expiresIn: { type: 'integer', description: 'Secret expiration in seconds' }, + webhookUrl: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + fulfilledAt: { type: 'string', format: 'date-time', nullable: true }, + secretId: { type: 'string', nullable: true }, + }, + }, + SecretRequestDetail: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + status: { + type: 'string', + enum: ['pending', 'fulfilled', 'expired', 'cancelled'], + }, + maxViews: { type: 'integer' }, + expiresIn: { type: 'integer' }, + preventBurn: { type: 'boolean' }, + allowedIp: { type: 'string', nullable: true }, + webhookUrl: { type: 'string', nullable: true }, + token: { type: 'string' }, + creatorLink: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + fulfilledAt: { type: 'string', format: 'date-time', nullable: true }, + secretId: { type: 'string', nullable: true }, + }, + }, + CreateSecretRequestBody: { + type: 'object', + required: ['title', 'expiresIn', 'validFor'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string', maxLength: 1000 }, + maxViews: { type: 'integer', minimum: 1, maximum: 9999, default: 1 }, + expiresIn: { + type: 'integer', + description: 'How long the created secret lives (seconds)', + enum: [ + 300, 1800, 3600, 14400, 43200, 86400, 259200, 604800, 1209600, 2419200, + ], + }, + validFor: { + type: 'integer', + description: 'How long the request link is valid (seconds)', + enum: [3600, 43200, 86400, 259200, 604800, 1209600, 2592000], + }, + allowedIp: { + type: 'string', + nullable: true, + description: 'IP/CIDR restriction for viewing the secret', + }, + preventBurn: { + type: 'boolean', + default: false, + description: 'Keep secret even after max views reached', + }, + webhookUrl: { + type: 'string', + format: 'uri', + description: 'URL to receive webhook when secret is submitted', + }, + }, + }, + ApiKey: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + keyPrefix: { type: 'string', description: 'First 16 characters of the key' }, + lastUsedAt: { type: 'string', format: 'date-time', nullable: true }, + expiresAt: { type: 'string', format: 'date-time', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + SecretListItem: { + type: 'object', + properties: { + id: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + views: { type: 'integer' }, + isPasswordProtected: { type: 'boolean' }, + ipRange: { type: 'string', nullable: true }, + isBurnable: { type: 'boolean' }, + fileCount: { type: 'integer' }, + }, + }, + Secret: { + type: 'object', + properties: { + id: { type: 'string' }, + secret: { type: 'string', description: 'Encrypted secret content (base64)' }, + title: { type: 'string', nullable: true }, + salt: { type: 'string' }, + views: { type: 'integer' }, + expiresAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + isBurnable: { type: 'boolean' }, + ipRange: { type: 'string', nullable: true }, + files: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + filename: { type: 'string' }, + }, + }, + }, + }, + }, + CreateSecretRequest: { + type: 'object', + required: ['secret', 'salt', 'expiresAt'], + properties: { + secret: { type: 'string', description: 'Encrypted secret content' }, + title: { type: 'string', nullable: true }, + salt: { type: 'string', description: 'Salt used for encryption' }, + password: { type: 'string', description: 'Optional password protection' }, + expiresAt: { + type: 'integer', + description: 'Expiration time in seconds from now', + }, + views: { type: 'integer', default: 1, description: 'Number of allowed views' }, + isBurnable: { type: 'boolean', default: false }, + ipRange: { + type: 'string', + nullable: true, + description: 'IP range restriction (CIDR notation)', + }, + fileIds: { + type: 'array', + items: { type: 'string' }, + description: 'IDs of uploaded files to attach', + }, + }, + }, + PaginationMeta: { + type: 'object', + properties: { + total: { type: 'integer' }, + skip: { type: 'integer' }, + take: { type: 'integer' }, + page: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + PublicInstanceSettings: { + type: 'object', + properties: { + instanceName: { type: 'string' }, + instanceDescription: { type: 'string' }, + allowRegistration: { type: 'boolean' }, + defaultSecretExpiration: { type: 'integer' }, + maxSecretSize: { type: 'integer' }, + allowPasswordProtection: { type: 'boolean' }, + allowIpRestriction: { type: 'boolean' }, + requireRegisteredUser: { type: 'boolean' }, + }, + }, + InstanceSettings: { + type: 'object', + properties: { + instanceName: { type: 'string' }, + instanceDescription: { type: 'string' }, + allowRegistration: { type: 'boolean' }, + requireEmailVerification: { type: 'boolean' }, + defaultSecretExpiration: { type: 'integer' }, + maxSecretSize: { type: 'integer' }, + allowPasswordProtection: { type: 'boolean' }, + allowIpRestriction: { type: 'boolean' }, + enableRateLimiting: { type: 'boolean' }, + rateLimitRequests: { type: 'integer' }, + rateLimitWindow: { type: 'integer' }, + requireInviteCode: { type: 'boolean' }, + allowedEmailDomains: { type: 'string' }, + requireRegisteredUser: { type: 'boolean' }, + webhookEnabled: { type: 'boolean' }, + webhookUrl: { type: 'string' }, + webhookSecret: { type: 'string' }, + webhookOnView: { type: 'boolean' }, + webhookOnBurn: { type: 'boolean' }, + metricsEnabled: { + type: 'boolean', + description: 'Enable Prometheus metrics endpoint', + }, + metricsSecret: { + type: 'string', + description: 'Bearer token for authenticating metrics endpoint requests', + }, + }, + }, + InviteCode: { + type: 'object', + properties: { + id: { type: 'string' }, + code: { type: 'string' }, + maxUses: { type: 'integer' }, + uses: { type: 'integer' }, + expiresAt: { type: 'string', format: 'date-time', nullable: true }, + isActive: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + createdBy: { type: 'string' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + email: { type: 'string' }, + role: { type: 'string' }, + banned: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + HealthCheckResponse: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['healthy', 'unhealthy'], + description: 'Overall health status', + }, + timestamp: { type: 'string', format: 'date-time' }, + checks: { + type: 'object', + properties: { + database: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + latency_ms: { type: 'integer' }, + error: { type: 'string' }, + }, + }, + storage: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + error: { type: 'string' }, + }, + }, + memory: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + heap_used_mb: { type: 'integer' }, + heap_total_mb: { type: 'integer' }, + rss_mb: { type: 'integer' }, + rss_threshold_mb: { type: 'integer' }, + }, + }, + }, + }, + }, + example: { + status: 'healthy', + timestamp: '2024-01-15T10:30:00.000Z', + checks: { + database: { status: 'healthy', latency_ms: 2 }, + storage: { status: 'healthy' }, + memory: { + status: 'healthy', + heap_used_mb: 128, + heap_total_mb: 256, + rss_mb: 312, + rss_threshold_mb: 1024, + }, + }, + }, + }, + }, + responses: { + Unauthorized: { + description: 'Unauthorized - authentication required', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + Forbidden: { + description: 'Forbidden - admin access required', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +}; + +// OpenAPI JSON spec endpoint +openapi.get('/openapi.json', (c) => c.json(spec)); + +// Swagger UI +openapi.get( + '/docs', + swaggerUI({ + url: '/api/openapi.json', + }) +); + +export default openapi; diff --git a/api/routes.ts b/api/routes.ts new file mode 100644 index 0000000..5d63b40 --- /dev/null +++ b/api/routes.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono'; +import { getEnabledSocialProviders } from './auth'; +import openapi from './openapi'; +import accountRoute from './routes/account'; +import analyticsRoute from './routes/analytics'; +import apiKeysRoute from './routes/api-keys'; +import filesRoute from './routes/files'; +import healthRoute from './routes/health'; +import instanceRoute from './routes/instance'; +import { invitePublicRoute, inviteRoute } from './routes/invites'; +import metricsRoute from './routes/metrics'; +import secretRequestsRoute from './routes/secret-requests'; +import secretsRoute from './routes/secrets'; +import setupRoute from './routes/setup'; +import { userRoute } from './routes/user'; + +// Create a new router +const routes = new Hono() + .route('/secrets', secretsRoute) + .route('/secret-requests', secretRequestsRoute) + .route('/account', accountRoute) + .route('/files', filesRoute) + .route('/user', userRoute) + .route('/instance', instanceRoute) + .route('/analytics', analyticsRoute) + .route('/invites/public', invitePublicRoute) + .route('/invites', inviteRoute) + .route('/setup', setupRoute) + .route('/api-keys', apiKeysRoute) + .route('/metrics', metricsRoute) + .route('/health', healthRoute) + .route('/', openapi) + // Legacy liveness endpoint (kept for backwards compatibility) + .get('/healthz', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString() })) + .get('/config/social-providers', (c) => { + const providers = getEnabledSocialProviders(); + const baseUrl = process.env.HEMMELIG_BASE_URL || c.req.header('origin') || ''; + const callbackBaseUrl = baseUrl ? `${baseUrl}/api/auth/callback` : ''; + + return c.json({ + providers, + callbackBaseUrl, + }); + }); + +export default routes; + +export type AppType = typeof routes; diff --git a/api/routes/account.ts b/api/routes/account.ts new file mode 100644 index 0000000..dd81489 --- /dev/null +++ b/api/routes/account.ts @@ -0,0 +1,130 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { authMiddleware } from '../middlewares/auth'; +import { updateAccountSchema, updatePasswordSchema } from '../validations/account'; + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>(); + +// Get user account information +app.get('/', authMiddleware, async (c) => { + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + return c.json({ + username: user.username, + email: user.email, + }); +}); + +// Update user account information +app.put('/', authMiddleware, zValidator('json', updateAccountSchema), async (c) => { + const user = c.get('user'); + const { username, email } = c.req.valid('json'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + // Check if username is taken by another user + if (username) { + const existingUser = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + if (existingUser && existingUser.id !== user.id) { + return c.json({ error: 'Username is already taken' }, 409); + } + } + + // Check if email is taken by another user + if (email) { + const existingEmail = await prisma.user.findFirst({ + where: { email }, + select: { id: true }, + }); + if (existingEmail && existingEmail.id !== user.id) { + return c.json({ error: 'Email is already taken' }, 409); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + username, + email, + }, + }); + + return c.json({ + username: updatedUser.username, + email: updatedUser.email, + }); + } catch (error) { + console.error('Failed to update account:', error); + return handleNotFound(error as Error & { code?: string }, c); + } +}); + +// Update user password +app.put('/password', authMiddleware, zValidator('json', updatePasswordSchema), async (c) => { + const user = c.get('user'); + const { currentPassword, newPassword } = c.req.valid('json'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + // Use better-auth's changePassword API + const result = await auth.api.changePassword({ + body: { + currentPassword, + newPassword, + }, + headers: c.req.raw.headers, + }); + + if (!result) { + return c.json({ error: 'Failed to change password' }, 500); + } + + return c.json({ message: 'Password updated successfully' }); + } catch (error) { + console.error('Failed to update password:', error); + const message = error instanceof Error ? error.message : 'Failed to update password'; + return c.json({ error: message }, 500); + } +}); + +// Delete user account +app.delete('/', authMiddleware, async (c) => { + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + await prisma.user.delete({ + where: { id: user.id }, + }); + + return c.json({ message: 'Account deleted successfully' }); + } catch (error) { + console.error('Failed to delete account:', error); + return handleNotFound(error as Error & { code?: string }, c); + } +}); + +export default app; diff --git a/api/routes/analytics.ts b/api/routes/analytics.ts new file mode 100644 index 0000000..b03e78c --- /dev/null +++ b/api/routes/analytics.ts @@ -0,0 +1,253 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { isbot } from 'isbot'; +import { z } from 'zod'; +import { + calculatePercentage, + createVisitorId, + getStartDateForTimeRange, + isAnalyticsEnabled, + isValidAnalyticsPath, +} from '../lib/analytics'; +import prisma from '../lib/db'; +import { getClientIp } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; + +const app = new Hono(); + +const trackSchema = z.object({ + path: z.string().max(255), +}); + +const timeRangeSchema = z.object({ + timeRange: z.enum(['7d', '14d', '30d']).default('30d'), +}); + +// POST /api/analytics/track - Public endpoint for visitor tracking +app.post('/track', zValidator('json', trackSchema), async (c) => { + if (!isAnalyticsEnabled()) { + return c.json({ success: false }, 403); + } + + const userAgent = c.req.header('user-agent') || ''; + + if (isbot(userAgent)) { + return c.json({ success: false }, 403); + } + + try { + const { path } = c.req.valid('json'); + + if (!isValidAnalyticsPath(path)) { + return c.json({ error: 'Invalid path format' }, 400); + } + + const uniqueId = createVisitorId(getClientIp(c), userAgent); + + await prisma.visitorAnalytics.create({ + data: { path, uniqueId }, + }); + + return c.json({ success: true }, 201); + } catch (error) { + console.error('Analytics tracking error:', error); + return c.json({ error: 'Failed to track analytics' }, 500); + } +}); + +// GET /api/analytics - Secret analytics (admin only) +app.get('/', authMiddleware, checkAdmin, zValidator('query', timeRangeSchema), async (c) => { + const { timeRange } = c.req.valid('query'); + const now = new Date(); + const startDate = getStartDateForTimeRange(timeRange); + + try { + // Use aggregations for basic counts - much more efficient than loading all records + const [aggregates, activeCount, typesCounts, dailyStats, secretRequestStats] = + await Promise.all([ + // Get total count and sum of views + prisma.secrets.aggregate({ + where: { createdAt: { gte: startDate } }, + _count: true, + _sum: { views: true }, + }), + // Count active (non-expired) secrets + prisma.secrets.count({ + where: { + createdAt: { gte: startDate }, + expiresAt: { gt: now }, + }, + }), + // Get counts for secret types in parallel + Promise.all([ + prisma.secrets.count({ + where: { createdAt: { gte: startDate }, password: { not: null } }, + }), + prisma.secrets.count({ + where: { + createdAt: { gte: startDate }, + ipRange: { not: null }, + NOT: { ipRange: '' }, + }, + }), + prisma.secrets.count({ + where: { createdAt: { gte: startDate }, isBurnable: true }, + }), + ]), + // For daily stats, we still need individual records but only select minimal fields + prisma.secrets.findMany({ + where: { createdAt: { gte: startDate } }, + select: { + createdAt: true, + views: true, + expiresAt: true, + }, + }), + // Secret request statistics + Promise.all([ + prisma.secretRequest.count({ + where: { createdAt: { gte: startDate } }, + }), + prisma.secretRequest.count({ + where: { createdAt: { gte: startDate }, status: 'fulfilled' }, + }), + ]), + ]); + + const totalSecrets = aggregates._count; + const totalViews = aggregates._sum.views || 0; + const activeSecrets = activeCount; + const expiredSecrets = totalSecrets - activeSecrets; + const averageViews = totalSecrets > 0 ? totalViews / totalSecrets : 0; + + const [passwordProtected, ipRestricted, burnable] = typesCounts; + const [totalSecretRequests, fulfilledSecretRequests] = secretRequestStats; + + // Process daily stats from minimal data + const dailyStatsMap = dailyStats.reduce( + (acc, secret) => { + const date = secret.createdAt.toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { date, secrets: 0, views: 0 }; + } + acc[date].secrets++; + acc[date].views += secret.views || 0; + return acc; + }, + {} as Record + ); + + // Calculate expiration stats from minimal data + const expirationDurations = dailyStats.map( + (s) => (s.expiresAt.getTime() - s.createdAt.getTime()) / (1000 * 60 * 60) + ); + const oneHour = expirationDurations.filter((d) => d <= 1).length; + const oneDay = expirationDurations.filter((d) => d > 1 && d <= 24).length; + const oneWeekPlus = expirationDurations.filter((d) => d > 24).length; + + return c.json({ + totalSecrets, + totalViews, + activeSecrets, + expiredSecrets, + averageViews: parseFloat(averageViews.toFixed(2)), + dailyStats: Object.values(dailyStatsMap), + secretTypes: { + passwordProtected: calculatePercentage(passwordProtected, totalSecrets), + ipRestricted: calculatePercentage(ipRestricted, totalSecrets), + burnable: calculatePercentage(burnable, totalSecrets), + }, + expirationStats: { + oneHour: calculatePercentage(oneHour, totalSecrets), + oneDay: calculatePercentage(oneDay, totalSecrets), + oneWeekPlus: calculatePercentage(oneWeekPlus, totalSecrets), + }, + secretRequests: { + total: totalSecretRequests, + fulfilled: fulfilledSecretRequests, + }, + }); + } catch (error) { + console.error('Failed to fetch analytics data:', error); + return c.json({ error: 'Failed to fetch analytics data' }, 500); + } +}); + +// GET /api/analytics/visitors - Visitor analytics data (admin only) +app.get('/visitors', authMiddleware, checkAdmin, async (c) => { + try { + const analytics = await prisma.visitorAnalytics.findMany({ + orderBy: { timestamp: 'desc' }, + take: 1000, + }); + return c.json(analytics); + } catch (error) { + console.error('Analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve analytics' }, 500); + } +}); + +// GET /api/analytics/visitors/unique - Aggregated unique visitor data (admin only) +app.get('/visitors/unique', authMiddleware, checkAdmin, async (c) => { + try { + const aggregatedData = await prisma.visitorAnalytics.groupBy({ + by: ['uniqueId', 'path'], + _count: { uniqueId: true }, + orderBy: { _count: { uniqueId: 'desc' } }, + }); + return c.json(aggregatedData); + } catch (error) { + console.error('Aggregated analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve aggregated analytics' }, 500); + } +}); + +// GET /api/analytics/visitors/daily - Daily visitor statistics (admin only) +app.get( + '/visitors/daily', + authMiddleware, + checkAdmin, + zValidator('query', timeRangeSchema), + async (c) => { + try { + const { timeRange } = c.req.valid('query'); + const startDate = getStartDateForTimeRange(timeRange); + + // Use raw SQL for efficient database-level aggregation + // This avoids loading all records into memory for high-traffic instances + const aggregatedData = await prisma.$queryRaw< + Array<{ + date: string; + unique_visitors: bigint; + total_visits: bigint; + paths: string; + }> + >` + SELECT + DATE(timestamp) as date, + COUNT(DISTINCT uniqueId) as unique_visitors, + COUNT(*) as total_visits, + GROUP_CONCAT(DISTINCT path) as paths + FROM visitor_analytics + WHERE timestamp >= ${startDate} + GROUP BY DATE(timestamp) + ORDER BY date ASC + `; + + // Convert BigInt to number for JSON serialization + const result = aggregatedData.map((row) => ({ + date: row.date, + unique_visitors: Number(row.unique_visitors), + total_visits: Number(row.total_visits), + paths: row.paths || '', + })); + + return c.json(result); + } catch (error) { + console.error('Daily analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve daily analytics' }, 500); + } + } +); + +export default app; diff --git a/api/routes/api-keys.ts b/api/routes/api-keys.ts new file mode 100644 index 0000000..145442d --- /dev/null +++ b/api/routes/api-keys.ts @@ -0,0 +1,156 @@ +import { zValidator } from '@hono/zod-validator'; +import { createHash, randomBytes } from 'crypto'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { sendWebhook } from '../lib/webhook'; +import { authMiddleware } from '../middlewares/auth'; + +const createApiKeySchema = z.object({ + name: z.string().min(1).max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +const deleteApiKeySchema = z.object({ + id: z.string(), +}); + +function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +function generateApiKey(): string { + const prefix = 'hemmelig'; + const key = randomBytes(24).toString('base64url'); + return `${prefix}_${key}`; +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .use(authMiddleware) + .get('/', async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const apiKeys = await prisma.apiKey.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + keyPrefix: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return c.json(apiKeys); + } catch (error) { + console.error('Failed to list API keys:', error); + return c.json({ error: 'Failed to list API keys' }, 500); + } + }) + .post('/', zValidator('json', createApiKeySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { name, expiresInDays } = c.req.valid('json'); + + try { + // Check API key limit (max 5 per user) + const existingCount = await prisma.apiKey.count({ + where: { userId: user.id }, + }); + + if (existingCount >= 5) { + return c.json({ error: 'Maximum API key limit reached (5)' }, 400); + } + + const rawKey = generateApiKey(); + const keyHash = hashApiKey(rawKey); + const keyPrefix = rawKey.substring(0, 16); + + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) + : null; + + const apiKey = await prisma.apiKey.create({ + data: { + name, + keyHash, + keyPrefix, + userId: user.id, + expiresAt, + }, + select: { + id: true, + name: true, + keyPrefix: true, + expiresAt: true, + createdAt: true, + }, + }); + + // Send webhook for API key creation + sendWebhook('apikey.created', { + apiKeyId: apiKey.id, + name: apiKey.name, + expiresAt: apiKey.expiresAt?.toISOString() || null, + userId: user.id, + }); + + // Return the raw key only once - it cannot be retrieved again + return c.json( + { + ...apiKey, + key: rawKey, + }, + 201 + ); + } catch (error) { + console.error('Failed to create API key:', error); + return c.json({ error: 'Failed to create API key' }, 500); + } + }) + .delete('/:id', zValidator('param', deleteApiKeySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { id } = c.req.valid('param'); + + try { + // Ensure the API key belongs to the user + const apiKey = await prisma.apiKey.findFirst({ + where: { id, userId: user.id }, + }); + + if (!apiKey) { + return c.json({ error: 'API key not found' }, 404); + } + + await prisma.apiKey.delete({ where: { id } }); + + return c.json({ success: true }); + } catch (error) { + console.error('Failed to delete API key:', error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); + +export default app; + +// Export helper for middleware +export { hashApiKey }; diff --git a/api/routes/files.ts b/api/routes/files.ts new file mode 100644 index 0000000..175ad6e --- /dev/null +++ b/api/routes/files.ts @@ -0,0 +1,134 @@ +import { zValidator } from '@hono/zod-validator'; +import { createReadStream, createWriteStream } from 'fs'; +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; +import { nanoid } from 'nanoid'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import { z } from 'zod'; +import config from '../config'; +import prisma from '../lib/db'; +import { generateSafeFilePath, getMaxFileSize, isPathSafe } from '../lib/files'; +import { getInstanceSettings } from '../lib/settings'; + +const files = new Hono(); + +const fileIdParamSchema = z.object({ + id: z.string(), +}); + +files.get('/:id', zValidator('param', fileIdParamSchema), async (c) => { + const { id } = c.req.valid('param'); + + try { + // Fetch file with its associated secrets to verify access + const file = await prisma.file.findUnique({ + where: { id }, + include: { + secrets: { + select: { + id: true, + views: true, + expiresAt: true, + }, + }, + }, + }); + + if (!file) { + return c.json({ error: 'File not found' }, 404); + } + + // Security: Verify the file is associated with at least one valid (non-expired, has views) secret + // This prevents direct file access without going through the secret viewing flow + const hasValidSecret = file.secrets.some((secret) => { + const now = new Date(); + const hasViewsRemaining = secret.views === null || secret.views > 0; + const notExpired = secret.expiresAt > now; + return hasViewsRemaining && notExpired; + }); + + if (!hasValidSecret) { + return c.json({ error: 'File not found' }, 404); + } + + // Validate path is within upload directory to prevent path traversal + if (!isPathSafe(file.path)) { + console.error(`Path traversal attempt detected: ${file.path}`); + return c.json({ error: 'File not found' }, 404); + } + + // Stream the file instead of loading it entirely into memory + const nodeStream = createReadStream(file.path); + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + + return stream(c, async (s) => { + s.onAbort(() => { + nodeStream.destroy(); + }); + await s.pipe(webStream); + }); + } catch (error) { + console.error('Failed to download file:', error); + return c.json({ error: 'Failed to download file' }, 500); + } +}); + +files.post('/', async (c) => { + try { + // Check if file uploads are allowed + let allowFileUploads = true; + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + allowFileUploads = managedSettings?.allowFileUploads ?? true; + } else { + const instanceSettings = await getInstanceSettings(); + allowFileUploads = instanceSettings?.allowFileUploads ?? true; + } + + if (!allowFileUploads) { + return c.json({ error: 'File uploads are disabled on this instance.' }, 403); + } + + const body = await c.req.parseBody(); + const file = body['file']; + + if (!(file instanceof File)) { + return c.json({ error: 'File is required and must be a file.' }, 400); + } + + const maxFileSize = getMaxFileSize(); + if (file.size > maxFileSize) { + return c.json( + { error: `File size exceeds the limit of ${maxFileSize / 1024 / 1024}MB.` }, + 413 + ); + } + + const id = nanoid(); + const safePath = generateSafeFilePath(id, file.name); + + if (!safePath) { + console.error(`Path traversal attempt in upload: ${file.name}`); + return c.json({ error: 'Invalid filename' }, 400); + } + + // Stream the file to disk instead of loading it entirely into memory + const webStream = file.stream(); + const nodeStream = Readable.fromWeb(webStream as import('stream/web').ReadableStream); + const writeStream = createWriteStream(safePath.path); + + await pipeline(nodeStream, writeStream); + + const newFile = await prisma.file.create({ + data: { id, filename: safePath.filename, path: safePath.path }, + }); + + return c.json({ id: newFile.id }, 201); + } catch (error) { + console.error('Failed to upload file:', error); + return c.json({ error: 'Failed to upload file' }, 500); + } +}); + +export default files; diff --git a/api/routes/health.ts b/api/routes/health.ts new file mode 100644 index 0000000..ac0adbb --- /dev/null +++ b/api/routes/health.ts @@ -0,0 +1,131 @@ +import { constants } from 'fs'; +import { access, unlink, writeFile } from 'fs/promises'; +import { Hono } from 'hono'; +import { join } from 'path'; +import prisma from '../lib/db'; +import { UPLOAD_DIR } from '../lib/files'; + +const app = new Hono(); + +type CheckStatus = 'healthy' | 'unhealthy'; + +type CheckResult = { + status: CheckStatus; + latency_ms?: number; + error?: string; + [key: string]: unknown; +}; + +type HealthResponse = { + status: CheckStatus; + timestamp: string; + checks: { + database: CheckResult; + storage: CheckResult; + memory: CheckResult; + }; +}; + +/** + * Check database connectivity by executing a simple query + */ +async function checkDatabase(): Promise { + const start = Date.now(); + try { + await prisma.$queryRaw`SELECT 1`; + return { + status: 'healthy', + latency_ms: Date.now() - start, + }; + } catch (error) { + return { + status: 'unhealthy', + latency_ms: Date.now() - start, + error: error instanceof Error ? error.message : 'Database connection failed', + }; + } +} + +/** + * Check file storage is accessible and writable + */ +async function checkStorage(): Promise { + const testFile = join(UPLOAD_DIR, `.health-check-${Date.now()}`); + try { + // Check directory exists and is accessible + await access(UPLOAD_DIR, constants.R_OK | constants.W_OK); + + // Try to write and delete a test file + await writeFile(testFile, 'health-check'); + await unlink(testFile); + + return { + status: 'healthy', + }; + } catch (error) { + return { + status: 'unhealthy', + error: error instanceof Error ? error.message : 'Storage check failed', + }; + } +} + +/** + * Check memory usage is within acceptable bounds + * Note: heapUsed/heapTotal ratio is often high (90%+) in normal Node.js operation + * since the heap grows dynamically. We use RSS-based threshold instead. + */ +function checkMemory(): CheckResult { + const memUsage = process.memoryUsage(); + const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); + const rssMB = Math.round(memUsage.rss / 1024 / 1024); + + // Consider unhealthy if RSS exceeds 1GB (reasonable default for most deployments) + const RSS_THRESHOLD_MB = 1024; + const isHealthy = rssMB < RSS_THRESHOLD_MB; + + return { + status: isHealthy ? 'healthy' : 'unhealthy', + heap_used_mb: heapUsedMB, + heap_total_mb: heapTotalMB, + rss_mb: rssMB, + rss_threshold_mb: RSS_THRESHOLD_MB, + }; +} + +/** + * GET /health/live - Liveness probe + * Simple check to verify the process is running + */ +app.get('/live', (c) => { + return c.json({ status: 'healthy', timestamp: new Date().toISOString() }); +}); + +/** + * GET /health/ready - Readiness probe + * Comprehensive check of all dependencies + */ +app.get('/ready', async (c) => { + const [database, storage] = await Promise.all([checkDatabase(), checkStorage()]); + + const memory = checkMemory(); + + const checks = { database, storage, memory }; + + const overallStatus: CheckStatus = Object.values(checks).every( + (check) => check.status === 'healthy' + ) + ? 'healthy' + : 'unhealthy'; + + const response: HealthResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + checks, + }; + + return c.json(response, overallStatus === 'healthy' ? 200 : 503); +}); + +export default app; diff --git a/api/routes/instance.ts b/api/routes/instance.ts new file mode 100644 index 0000000..de8a870 --- /dev/null +++ b/api/routes/instance.ts @@ -0,0 +1,169 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import config from '../config'; +import { ADMIN_SETTINGS_FIELDS, PUBLIC_SETTINGS_FIELDS } from '../lib/constants'; +import prisma from '../lib/db'; +import settingsCache, { setCachedInstanceSettings } from '../lib/settings'; +import { handleNotFound, isPublicUrl } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; +import { instanceSettingsSchema } from '../validations/instance'; + +const app = new Hono(); + +// GET /api/instance/managed - check if instance is in managed mode +app.get('/managed', async (c) => { + return c.json({ managed: config.isManaged() }); +}); + +// GET /api/instance/settings/public - public settings for all users +app.get('/settings/public', async (c) => { + try { + // In managed mode, return settings from environment variables + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + const publicSettings = Object.fromEntries( + Object.entries(managedSettings || {}).filter( + ([key]) => key in PUBLIC_SETTINGS_FIELDS + ) + ); + return c.json(publicSettings); + } + + let dbSettings = await prisma.instanceSettings.findFirst({ + select: PUBLIC_SETTINGS_FIELDS, + }); + + if (!dbSettings) { + const initialData = { + ...Object.fromEntries( + Object.entries(config.get('general')).filter(([, v]) => v !== undefined) + ), + ...Object.fromEntries( + Object.entries(config.get('security')).filter(([, v]) => v !== undefined) + ), + }; + + dbSettings = await prisma.instanceSettings.create({ + data: initialData, + select: PUBLIC_SETTINGS_FIELDS, + }); + } + + const configSettings = { + ...config.get('general'), + ...config.get('security'), + }; + const filteredConfigSettings = Object.fromEntries( + Object.entries(configSettings).filter( + ([key, value]) => value !== undefined && key in PUBLIC_SETTINGS_FIELDS + ) + ); + + const finalSettings = { + ...dbSettings, + ...filteredConfigSettings, + }; + + return c.json(finalSettings); + } catch (error) { + console.error('Failed to fetch public instance settings:', error); + return c.json({ error: 'Failed to fetch instance settings' }, 500); + } +}); + +// GET /api/instance/settings - admin only +app.get('/settings', authMiddleware, checkAdmin, async (c) => { + try { + // In managed mode, return settings from environment variables + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + return c.json(managedSettings); + } + + let dbSettings = await prisma.instanceSettings.findFirst({ select: ADMIN_SETTINGS_FIELDS }); + + if (!dbSettings) { + const initialData = { + ...Object.fromEntries( + Object.entries(config.get('general')).filter(([, v]) => v !== undefined) + ), + ...Object.fromEntries( + Object.entries(config.get('security')).filter(([, v]) => v !== undefined) + ), + }; + + dbSettings = await prisma.instanceSettings.create({ + data: initialData, + select: ADMIN_SETTINGS_FIELDS, + }); + } + + const configSettings = { + ...config.get('general'), + ...config.get('security'), + }; + const filteredConfigSettings = Object.fromEntries( + Object.entries(configSettings).filter(([, value]) => value !== undefined) + ); + + const finalSettings = { + ...dbSettings, + ...filteredConfigSettings, + }; + + return c.json(finalSettings); + } catch (error) { + console.error('Failed to fetch instance settings:', error); + return c.json({ error: 'Failed to fetch instance settings' }, 500); + } +}); + +// PUT /api/instance/settings +app.put( + '/settings', + authMiddleware, + checkAdmin, + zValidator('json', instanceSettingsSchema), + async (c) => { + // Block updates in managed mode + if (config.isManaged()) { + return c.json( + { error: 'Instance is in managed mode. Settings cannot be modified.' }, + 403 + ); + } + + const body = c.req.valid('json'); + + if (body.webhookUrl && body.webhookUrl !== '' && !(await isPublicUrl(body.webhookUrl))) { + return c.json({ error: 'Webhook URL cannot point to private/internal addresses' }, 400); + } + + try { + const settings = await prisma.instanceSettings.findFirst(); + + if (!settings) { + return c.json({ error: 'Instance settings not found' }, 404); + } + + const updatedSettings = await prisma.instanceSettings.update({ + where: { id: settings.id }, + data: body, + select: ADMIN_SETTINGS_FIELDS, + }); + + const currentSettings = settingsCache.get('instanceSettings'); + setCachedInstanceSettings({ + ...currentSettings, + ...updatedSettings, + }); + + return c.json(updatedSettings); + } catch (error) { + console.error('Failed to update instance settings:', error); + return handleNotFound(error as Error & { code?: string }, c); + } + } +); + +export default app; diff --git a/api/routes/invites.ts b/api/routes/invites.ts new file mode 100644 index 0000000..97713b7 --- /dev/null +++ b/api/routes/invites.ts @@ -0,0 +1,151 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { auth } from '../auth'; +import { TIME } from '../lib/constants'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; + +const createInviteSchema = z.object({ + maxUses: z.number().int().min(1).max(100).optional().default(1), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +const codeSchema = z.object({ code: z.string() }); + +// Public route for validating invite codes (no auth required) +export const invitePublicRoute = new Hono() + .post('/validate', zValidator('json', codeSchema), async (c) => { + const { code } = c.req.valid('json'); + + try { + const invite = await prisma.inviteCode.findUnique({ + where: { code: code.toUpperCase() }, + }); + + if (!invite || !invite.isActive) { + return c.json({ error: 'Invalid invite code' }, 400); + } + + if (invite.expiresAt && new Date() > invite.expiresAt) { + return c.json({ error: 'Invite code has expired' }, 400); + } + + if (invite.maxUses && invite.uses >= invite.maxUses) { + return c.json({ error: 'Invite code has reached maximum uses' }, 400); + } + + return c.json({ valid: true }); + } catch (error) { + console.error('Failed to validate invite code:', error); + return c.json({ error: 'Failed to validate invite code' }, 500); + } + }) + .post('/use', zValidator('json', z.object({ code: z.string() })), async (c) => { + const { code } = c.req.valid('json'); + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + const userId = user.id; + + try { + const invite = await prisma.inviteCode.findUnique({ + where: { code: code.toUpperCase() }, + }); + + if (!invite || !invite.isActive) { + return c.json({ error: 'Invalid invite code' }, 400); + } + + if (invite.expiresAt && new Date() > invite.expiresAt) { + return c.json({ error: 'Invite code has expired' }, 400); + } + + if (invite.maxUses && invite.uses >= invite.maxUses) { + return c.json({ error: 'Invite code has reached maximum uses' }, 400); + } + + await prisma.$transaction([ + prisma.inviteCode.update({ + where: { id: invite.id }, + data: { uses: { increment: 1 } }, + }), + prisma.user.update({ + where: { id: userId }, + data: { inviteCodeUsed: code.toUpperCase() }, + }), + ]); + + return c.json({ success: true }); + } catch (error) { + console.error('Failed to use invite code:', error); + return c.json({ error: 'Failed to use invite code' }, 500); + } + }); + +// Protected routes for admin invite management +export const inviteRoute = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .use(authMiddleware) + .use(checkAdmin) + .get('/', async (c) => { + try { + const invites = await prisma.inviteCode.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return c.json(invites); + } catch (error) { + console.error('Failed to list invite codes:', error); + return c.json({ error: 'Failed to list invite codes' }, 500); + } + }) + .post('/', zValidator('json', createInviteSchema), async (c) => { + const { maxUses, expiresInDays } = c.req.valid('json'); + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const code = nanoid(12).toUpperCase(); + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * TIME.DAY_MS) + : null; + + const invite = await prisma.inviteCode.create({ + data: { + code, + maxUses, + expiresAt, + createdBy: user.id, + }, + }); + + return c.json(invite, 201); + } catch (error) { + console.error('Failed to create invite code:', error); + return c.json({ error: 'Failed to create invite code' }, 500); + } + }) + .delete('/:id', zValidator('param', z.object({ id: z.string() })), async (c) => { + const { id } = c.req.valid('param'); + + try { + await prisma.inviteCode.update({ + where: { id }, + data: { isActive: false }, + }); + + return c.json({ success: true }); + } catch (error) { + console.error(`Failed to delete invite code ${id}:`, error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); diff --git a/api/routes/metrics.ts b/api/routes/metrics.ts new file mode 100644 index 0000000..b619c67 --- /dev/null +++ b/api/routes/metrics.ts @@ -0,0 +1,159 @@ +import { timingSafeEqual } from 'crypto'; +import { Hono } from 'hono'; +import { collectDefaultMetrics, Gauge, Histogram, register, Registry } from 'prom-client'; +import config from '../config'; +import prisma from '../lib/db'; +import { getInstanceSettings } from '../lib/settings'; + +const app = new Hono(); + +// Create a custom registry +const metricsRegistry = new Registry(); + +// Collect default Node.js metrics (memory, CPU, event loop, etc.) +collectDefaultMetrics({ register: metricsRegistry }); + +// Custom application metrics +const activeSecretsGauge = new Gauge({ + name: 'hemmelig_secrets_active_count', + help: 'Current number of active (unexpired) secrets', + registers: [metricsRegistry], +}); + +const totalUsersGauge = new Gauge({ + name: 'hemmelig_users_total', + help: 'Total number of registered users', + registers: [metricsRegistry], +}); + +const visitorsUnique30dGauge = new Gauge({ + name: 'hemmelig_visitors_unique_30d', + help: 'Unique visitors in the last 30 days', + registers: [metricsRegistry], +}); + +const visitorsViews30dGauge = new Gauge({ + name: 'hemmelig_visitors_views_30d', + help: 'Total page views in the last 30 days', + registers: [metricsRegistry], +}); + +const httpRequestDuration = new Histogram({ + name: 'hemmelig_http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [metricsRegistry], +}); + +// Function to update gauge metrics from database +async function updateGaugeMetrics() { + try { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + // Count active secrets (not expired) + const activeSecrets = await prisma.secrets.count({ + where: { + expiresAt: { + gt: now, + }, + }, + }); + activeSecretsGauge.set(activeSecrets); + + // Count total users + const totalUsers = await prisma.user.count(); + totalUsersGauge.set(totalUsers); + + // Get visitor stats for the last 30 days + const visitorStats = await prisma.$queryRaw< + Array<{ unique_visitors: bigint; total_views: bigint }> + >` + SELECT + COUNT(DISTINCT uniqueId) as unique_visitors, + COUNT(*) as total_views + FROM visitor_analytics + WHERE timestamp >= ${thirtyDaysAgo} + `; + + if (visitorStats.length > 0) { + visitorsUnique30dGauge.set(Number(visitorStats[0].unique_visitors)); + visitorsViews30dGauge.set(Number(visitorStats[0].total_views)); + } + } catch (error) { + console.error('Failed to update metrics gauges:', error); + } +} + +// Helper function to verify Bearer token using constant-time comparison +function verifyBearerToken(authHeader: string | undefined, expectedSecret: string): boolean { + if (!authHeader || !expectedSecret) { + return false; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return false; + } + + const provided = Buffer.from(parts[1]); + const expected = Buffer.from(expectedSecret); + + // Pad to same length to prevent timing leaks on token length + const maxLen = Math.max(provided.length, expected.length); + const paddedProvided = Buffer.alloc(maxLen); + const paddedExpected = Buffer.alloc(maxLen); + provided.copy(paddedProvided); + expected.copy(paddedExpected); + + // Constant-time comparison to prevent timing attacks + return timingSafeEqual(paddedProvided, paddedExpected) && provided.length === expected.length; +} + +// GET /api/metrics - Prometheus metrics endpoint +app.get('/', async (c) => { + try { + // In managed mode, use environment-based settings; otherwise use database + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + + // Check if metrics are enabled + if (!settings?.metricsEnabled) { + return c.json({ error: 'Metrics endpoint is disabled' }, 404); + } + + // Verify authentication if secret is configured + if (settings.metricsSecret) { + const authHeader = c.req.header('Authorization'); + if (!verifyBearerToken(authHeader, settings.metricsSecret)) { + return c.json({ error: 'Unauthorized' }, 401); + } + } + + // Update gauge metrics before returning + await updateGaugeMetrics(); + + // Get metrics in Prometheus format + const metrics = await metricsRegistry.metrics(); + + return c.text(metrics, 200, { + 'Content-Type': register.contentType, + }); + } catch (error) { + console.error('Failed to generate metrics:', error); + return c.json({ error: 'Failed to generate metrics' }, 500); + } +}); + +export function observeHttpRequest( + method: string, + route: string, + statusCode: number, + duration: number +) { + httpRequestDuration.labels(method, route, String(statusCode)).observe(duration); +} + +export default app; diff --git a/api/routes/secret-requests.ts b/api/routes/secret-requests.ts new file mode 100644 index 0000000..574cd15 --- /dev/null +++ b/api/routes/secret-requests.ts @@ -0,0 +1,455 @@ +import { zValidator } from '@hono/zod-validator'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { isPublicUrl } from '../lib/utils'; +import { authMiddleware } from '../middlewares/auth'; +import { + createSecretRequestSchema, + processSecretRequestsQueryParams, + secretRequestIdParamSchema, + secretRequestsQuerySchema, + secretRequestTokenQuerySchema, + submitSecretRequestSchema, +} from '../validations/secret-requests'; + +// Webhook payload for secret request fulfillment +interface SecretRequestWebhookPayload { + event: 'secret_request.fulfilled'; + timestamp: string; + request: { + id: string; + title: string; + createdAt: string; + fulfilledAt: string; + }; + secret: { + id: string; + maxViews: number; + expiresAt: string; + }; +} + +// Send webhook notification when a secret request is fulfilled +async function sendSecretRequestWebhook( + webhookUrl: string, + webhookSecret: string, + payload: SecretRequestWebhookPayload +): Promise { + try { + const timestamp = Math.floor(Date.now() / 1000); + const payloadString = JSON.stringify(payload); + const signedPayload = `${timestamp}.${payloadString}`; + const signature = createHmac('sha256', webhookSecret).update(signedPayload).digest('hex'); + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Hemmelig-Event': 'secret_request.fulfilled', + 'X-Hemmelig-Signature': `sha256=${signature}`, + 'X-Hemmelig-Timestamp': timestamp.toString(), + 'X-Hemmelig-Request-Id': payload.request.id, + 'User-Agent': 'Hemmelig-Webhook/1.0', + }; + + // Retry with exponential backoff + const maxRetries = 3; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers, + body: payloadString, + signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris + redirect: 'error', // Prevent SSRF via open redirects + }); + + if (response.ok) return; + + // Don't retry for client errors (4xx) + if (response.status >= 400 && response.status < 500) { + console.error(`Secret request webhook delivery failed: ${response.status}`); + return; + } + } catch (error) { + if (attempt === maxRetries - 1) { + console.error('Secret request webhook delivery failed after retries:', error); + } + } + + // Exponential backoff: 1s, 2s, 4s + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } catch (error) { + console.error('Error preparing secret request webhook:', error); + } +} + +// Secure token comparison - constant time for all inputs +function validateToken(provided: string, stored: string): boolean { + try { + // Pad to same length to prevent timing leaks from length comparison + const providedBuf = Buffer.alloc(32); + const storedBuf = Buffer.alloc(32); + + const providedBytes = Buffer.from(provided, 'hex'); + const storedBytes = Buffer.from(stored, 'hex'); + + // Only copy valid bytes, rest stays as zeros + if (providedBytes.length === 32) providedBytes.copy(providedBuf); + if (storedBytes.length === 32) storedBytes.copy(storedBuf); + + // Always do the comparison, even if lengths were wrong + const match = timingSafeEqual(providedBuf, storedBuf); + + // Only return true if lengths were correct AND content matches + return providedBytes.length === 32 && storedBytes.length === 32 && match; + } catch { + return false; + } +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + // List user's secret requests (authenticated) + .get('/', authMiddleware, zValidator('query', secretRequestsQuerySchema), async (c) => { + try { + const user = c.get('user')!; // authMiddleware guarantees user exists + + const validatedQuery = c.req.valid('query'); + const { skip, take, status } = processSecretRequestsQueryParams(validatedQuery); + + const whereClause: { userId: string; status?: string } = { userId: user.id }; + if (status && status !== 'all') { + whereClause.status = status; + } + + const [items, total] = await Promise.all([ + prisma.secretRequest.findMany({ + where: whereClause, + skip, + take, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + description: true, + status: true, + maxViews: true, + expiresIn: true, + webhookUrl: true, + createdAt: true, + expiresAt: true, + fulfilledAt: true, + secretId: true, + }, + }), + prisma.secretRequest.count({ where: whereClause }), + ]); + + return c.json({ + data: items, + meta: { + total, + skip, + take, + page: Math.floor(skip / take) + 1, + totalPages: Math.ceil(total / take), + }, + }); + } catch (error) { + console.error('Failed to retrieve secret requests:', error); + return c.json({ error: 'Failed to retrieve secret requests' }, 500); + } + }) + // Create new secret request (authenticated) + .post('/', authMiddleware, zValidator('json', createSecretRequestSchema), async (c) => { + try { + const user = c.get('user')!; // authMiddleware guarantees user exists + + const data = c.req.valid('json'); + + if (data.webhookUrl && !(await isPublicUrl(data.webhookUrl))) { + return c.json( + { error: 'Webhook URL cannot point to private/internal addresses' }, + 400 + ); + } + + // Generate secure token (64 hex chars = 32 bytes) + const token = randomBytes(32).toString('hex'); + + // Generate webhook secret if webhook URL is provided + const webhookSecret = data.webhookUrl ? randomBytes(32).toString('hex') : null; + + const request = await prisma.secretRequest.create({ + data: { + title: data.title, + description: data.description, + maxViews: data.maxViews, + expiresIn: data.expiresIn, + allowedIp: data.allowedIp, + preventBurn: data.preventBurn, + webhookUrl: data.webhookUrl, + webhookSecret, + token, + userId: user.id, + expiresAt: new Date(Date.now() + data.validFor * 1000), + }, + }); + + // Get the base URL from the request + const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || ''; + + return c.json( + { + id: request.id, + creatorLink: `${origin}/request/${request.id}?token=${token}`, + webhookSecret, // Return once so requester can configure their webhook receiver + expiresAt: request.expiresAt, + }, + 201 + ); + } catch (error) { + console.error('Failed to create secret request:', error); + return c.json({ error: 'Failed to create secret request' }, 500); + } + }) + // Get single secret request details (authenticated, owner only) + .get('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => { + try { + const user = c.get('user')!; + const { id } = c.req.valid('param'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { + id: true, + title: true, + description: true, + status: true, + maxViews: true, + expiresIn: true, + preventBurn: true, + webhookUrl: true, + token: true, + createdAt: true, + expiresAt: true, + fulfilledAt: true, + secretId: true, + userId: true, + allowedIp: true, + }, + }); + + if (!request) { + return c.json({ error: 'Secret request not found' }, 404); + } + + if (request.userId !== user.id) { + return c.json({ error: 'Forbidden' }, 403); + } + + // Get the base URL from the request + const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || ''; + + return c.json({ + ...request, + creatorLink: `${origin}/request/${request.id}?token=${request.token}`, + }); + } catch (error) { + console.error('Failed to retrieve secret request:', error); + return c.json({ error: 'Failed to retrieve secret request' }, 500); + } + }) + // Cancel/delete secret request (authenticated, owner only) + .delete('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => { + try { + const user = c.get('user')!; + const { id } = c.req.valid('param'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { userId: true, status: true }, + }); + + if (!request) { + return c.json({ error: 'Secret request not found' }, 404); + } + + if (request.userId !== user.id) { + return c.json({ error: 'Forbidden' }, 403); + } + + // Only allow cancellation of pending requests + if (request.status !== 'pending') { + return c.json({ error: 'Can only cancel pending requests' }, 400); + } + + await prisma.secretRequest.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + + return c.json({ success: true, message: 'Secret request cancelled' }); + } catch (error) { + console.error('Failed to cancel secret request:', error); + return c.json({ error: 'Failed to cancel secret request' }, 500); + } + }) + // Get request info for Creator (public, requires token) + .get( + '/:id/info', + zValidator('param', secretRequestIdParamSchema), + zValidator('query', secretRequestTokenQuerySchema), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { token } = c.req.valid('query'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { + id: true, + title: true, + description: true, + status: true, + expiresAt: true, + token: true, + }, + }); + + if (!request || !validateToken(token, request.token)) { + return c.json({ error: 'Invalid or expired request' }, 404); + } + + if (request.status !== 'pending') { + return c.json({ error: 'Request already fulfilled or expired' }, 410); + } + + if (new Date() > request.expiresAt) { + // Update status to expired + await prisma.secretRequest.update({ + where: { id }, + data: { status: 'expired' }, + }); + return c.json({ error: 'Request has expired' }, 410); + } + + return c.json({ + id: request.id, + title: request.title, + description: request.description, + }); + } catch (error) { + console.error('Failed to retrieve secret request info:', error); + return c.json({ error: 'Failed to retrieve request info' }, 500); + } + } + ) + // Submit encrypted secret for request (public, requires token) + .post( + '/:id/submit', + zValidator('param', secretRequestIdParamSchema), + zValidator('query', secretRequestTokenQuerySchema), + zValidator('json', submitSecretRequestSchema), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { token } = c.req.valid('query'); + const { secret, title, salt } = c.req.valid('json'); + + // Use interactive transaction to prevent race conditions + const result = await prisma.$transaction(async (tx) => { + const request = await tx.secretRequest.findUnique({ + where: { id }, + }); + + if (!request || !validateToken(token, request.token)) { + return { error: 'Invalid request', status: 404 }; + } + + if (request.status !== 'pending') { + return { error: 'Request already fulfilled', status: 410 }; + } + + if (new Date() > request.expiresAt) { + await tx.secretRequest.update({ + where: { id }, + data: { status: 'expired' }, + }); + return { error: 'Request has expired', status: 410 }; + } + + // Calculate expiration time for the secret + const secretExpiresAt = new Date(Date.now() + request.expiresIn * 1000); + + // Create secret and update request atomically + const createdSecret = await tx.secrets.create({ + data: { + secret: Buffer.from(secret), + title: title ? Buffer.from(title) : Buffer.from([]), + salt, + views: request.maxViews, + ipRange: request.allowedIp, + isBurnable: !request.preventBurn, + expiresAt: secretExpiresAt, + }, + }); + + await tx.secretRequest.update({ + where: { id }, + data: { + status: 'fulfilled', + fulfilledAt: new Date(), + secretId: createdSecret.id, + }, + }); + + return { success: true, createdSecret, request, secretExpiresAt }; + }); + + if ('error' in result) { + return c.json({ error: result.error }, result.status as 404 | 410); + } + + const { createdSecret, request, secretExpiresAt } = result; + + // Send webhook notification (async, don't block response) + if (request.webhookUrl && request.webhookSecret) { + const webhookPayload: SecretRequestWebhookPayload = { + event: 'secret_request.fulfilled', + timestamp: new Date().toISOString(), + request: { + id: request.id, + title: request.title, + createdAt: request.createdAt.toISOString(), + fulfilledAt: new Date().toISOString(), + }, + secret: { + id: createdSecret.id, + maxViews: request.maxViews, + expiresAt: secretExpiresAt.toISOString(), + }, + }; + + sendSecretRequestWebhook( + request.webhookUrl, + request.webhookSecret, + webhookPayload + ).catch(console.error); + } + + // Return secret ID (client will construct full URL with decryption key) + return c.json({ secretId: createdSecret.id }, 201); + } catch (error) { + console.error('Failed to submit secret for request:', error); + return c.json({ error: 'Failed to submit secret' }, 500); + } + } + ); + +export default app; diff --git a/api/routes/secrets.ts b/api/routes/secrets.ts new file mode 100644 index 0000000..974dd60 --- /dev/null +++ b/api/routes/secrets.ts @@ -0,0 +1,343 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import config from '../config'; +import prisma from '../lib/db'; +import { compare, hash } from '../lib/password'; +import { getInstanceSettings } from '../lib/settings'; +import { handleNotFound } from '../lib/utils'; +import { sendWebhook } from '../lib/webhook'; +import { apiKeyOrAuthMiddleware } from '../middlewares/auth'; +import { ipRestriction } from '../middlewares/ip-restriction'; +import { + createSecretsSchema, + getSecretSchema, + processSecretsQueryParams, + secretsIdParamSchema, + secretsQuerySchema, +} from '../validations/secrets'; + +interface SecretCreateData { + salt: string; + secret: Uint8Array; + title?: Uint8Array | null; + password: string | null; + expiresAt: Date; + views?: number; + isBurnable?: boolean; + ipRange?: string | null; + files?: { connect: { id: string }[] }; + userId?: string; +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .get('/', apiKeyOrAuthMiddleware, zValidator('query', secretsQuerySchema), async (c) => { + try { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const validatedQuery = c.req.valid('query'); + const options = processSecretsQueryParams(validatedQuery); + const whereClause = { userId: user.id }; + + const [items, total] = await Promise.all([ + prisma.secrets.findMany({ + where: whereClause, + skip: options.skip, + take: options.take, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + createdAt: true, + expiresAt: true, + views: true, + password: true, + ipRange: true, + isBurnable: true, + _count: { + select: { files: true }, + }, + }, + }), + prisma.secrets.count({ where: whereClause }), + ]); + + const formattedItems = items.map((item) => ({ + id: item.id, + createdAt: item.createdAt, + expiresAt: item.expiresAt, + views: item.views, + isPasswordProtected: !!item.password, + ipRange: item.ipRange, + isBurnable: item.isBurnable, + fileCount: item._count.files, + })); + + return c.json({ + data: formattedItems, + meta: { + total, + skip: options.skip, + take: options.take, + page: Math.floor(options.skip / options.take) + 1, + totalPages: Math.ceil(total / options.take), + }, + }); + } catch (error) { + console.error('Failed to retrieve secrets:', error); + return c.json( + { + error: 'Failed to retrieve secrets', + }, + 500 + ); + } + }) + .post( + '/:id', + zValidator('param', secretsIdParamSchema), + zValidator('json', getSecretSchema), + ipRestriction, + async (c) => { + try { + const { id } = c.req.valid('param'); + const data = c.req.valid('json'); + + // Atomically retrieve secret and consume view in a single transaction + const result = await prisma.$transaction(async (tx) => { + const item = await tx.secrets.findUnique({ + where: { id }, + select: { + id: true, + secret: true, + title: true, + ipRange: true, + views: true, + expiresAt: true, + createdAt: true, + isBurnable: true, + password: true, + salt: true, + files: { + select: { id: true, filename: true }, + }, + }, + }); + + if (!item) { + return { error: 'Secret not found', status: 404 as const }; + } + + // Check if secret has no views remaining (already consumed) + if (item.views !== null && item.views <= 0) { + return { error: 'Secret not found', status: 404 as const }; + } + + // Verify password if required + if (item.password) { + const isValidPassword = await compare(data.password!, item.password); + if (!isValidPassword) { + return { error: 'Invalid password', status: 401 as const }; + } + } + + // Consume the view atomically with retrieval + const newViews = item.views! - 1; + + // If burnable and last view, delete the secret after returning data + if (item.isBurnable && newViews <= 0) { + await tx.secrets.delete({ where: { id } }); + + // Send webhook for burned secret + sendWebhook('secret.burned', { + secretId: id, + hasPassword: !!item.password, + hasIpRestriction: !!item.ipRange, + }); + } else { + // Decrement views + await tx.secrets.update({ + where: { id }, + data: { views: newViews }, + }); + + // Send webhook for viewed secret + sendWebhook('secret.viewed', { + secretId: id, + hasPassword: !!item.password, + hasIpRestriction: !!item.ipRange, + viewsRemaining: newViews, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _password, ...itemWithoutPassword } = item; + return { + ...itemWithoutPassword, + views: newViews, + burned: item.isBurnable && newViews <= 0, + }; + }); + + if ('error' in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json(result); + } catch (error) { + console.error(`Failed to retrieve item ${c.req.param('id')}:`, error); + return c.json( + { + error: 'Failed to retrieve item', + }, + 500 + ); + } + } + ) + .get('/:id/check', zValidator('param', secretsIdParamSchema), ipRestriction, async (c) => { + try { + const { id } = c.req.valid('param'); + + const item = await prisma.secrets.findUnique({ + where: { id }, + select: { + id: true, + views: true, + title: true, + password: true, + }, + }); + + if (!item) { + return c.json({ error: 'Secret not found' }, 404); + } + + // Check if secret has no views remaining (already consumed) + if (item.views !== null && item.views <= 0) { + return c.json({ error: 'Secret not found' }, 404); + } + + return c.json({ + views: item.views, + title: item.title, + isPasswordProtected: !!item.password, + }); + } catch (error) { + console.error(`Failed to check secret ${c.req.param('id')}:`, error); + return c.json( + { + error: 'Failed to check secret', + }, + 500 + ); + } + }) + .post('/', zValidator('json', createSecretsSchema), async (c) => { + try { + const user = c.get('user'); + + // Check if only registered users can create secrets + // In managed mode, use environment-based settings; otherwise use database + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + if (settings?.requireRegisteredUser && !user) { + return c.json({ error: 'Only registered users can create secrets' }, 401); + } + + const validatedData = c.req.valid('json'); + + // Enforce dynamic maxSecretSize from instance settings (in KB) + const maxSizeKB = settings?.maxSecretSize ?? 1024; + const maxSizeBytes = maxSizeKB * 1024; + if (validatedData.secret.length > maxSizeBytes) { + return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413); + } + + const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData; + + const data: SecretCreateData = { + ...rest, + salt, + // Title is required by the database, default to empty Uint8Array if not provided + title: title ?? new Uint8Array(0), + password: password ? await hash(password) : null, + expiresAt: new Date(Date.now() + expiresAt * 1000), + ...(fileIds && { + files: { connect: fileIds.map((id: string) => ({ id })) }, + }), + }; + + if (user) { + data.userId = user.id; + } + + const item = await prisma.secrets.create({ data }); + + return c.json({ id: item.id }, 201); + } catch (error: unknown) { + console.error('Failed to create secrets:', error); + + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { + const prismaError = error as { meta?: { target?: string } }; + return c.json( + { + error: 'Could not create secrets', + details: prismaError.meta?.target, + }, + 409 + ); + } + + return c.json( + { + error: 'Failed to create secret', + }, + 500 + ); + } + }) + .delete('/:id', zValidator('param', secretsIdParamSchema), async (c) => { + try { + const { id } = c.req.valid('param'); + + // Use transaction to prevent race conditions + const secret = await prisma.$transaction(async (tx) => { + // Get secret info before deleting for webhook + const secretData = await tx.secrets.findUnique({ + where: { id }, + select: { id: true, password: true, ipRange: true }, + }); + + await tx.secrets.delete({ where: { id } }); + + return secretData; + }); + + // Send webhook for manually burned secret + if (secret) { + sendWebhook('secret.burned', { + secretId: id, + hasPassword: !!secret.password, + hasIpRestriction: !!secret.ipRange, + }); + } + + return c.json({ + success: true, + message: 'Secret deleted successfully', + }); + } catch (error) { + console.error(`Failed to delete secret ${c.req.param('id')}:`, error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); + +export default app; diff --git a/api/routes/setup.ts b/api/routes/setup.ts new file mode 100644 index 0000000..5ffc190 --- /dev/null +++ b/api/routes/setup.ts @@ -0,0 +1,82 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { passwordSchema } from '../validations/password'; + +const setupSchema = z.object({ + email: z.string().email(), + password: passwordSchema, + username: z.string().min(3).max(32), + name: z.string().min(1).max(100), +}); + +const app = new Hono() + // Check if setup is needed (no users exist) + .get('/status', async (c) => { + try { + const userCount = await prisma.user.count(); + return c.json({ + needsSetup: userCount === 0, + }); + } catch (error) { + console.error('Failed to check setup status:', error); + return c.json({ error: 'Failed to check setup status' }, 500); + } + }) + // Complete initial setup - create first admin user + .post('/complete', zValidator('json', setupSchema), async (c) => { + try { + // Check if any users already exist + const userCount = await prisma.user.count(); + if (userCount > 0) { + return c.json({ error: 'Setup already completed' }, 403); + } + + const { email, password, username, name } = c.req.valid('json'); + + // Create the admin user using better-auth + const result = await auth.api.signUpEmail({ + body: { + email, + password, + name, + username, + }, + }); + + if (!result.user) { + return c.json({ error: 'Failed to create admin user' }, 500); + } + + // Update user to be admin + await prisma.user.update({ + where: { id: result.user.id }, + data: { role: 'admin' }, + }); + + // Create initial instance settings if not exists + const existingSettings = await prisma.instanceSettings.findFirst(); + if (!existingSettings) { + await prisma.instanceSettings.create({ + data: {}, + }); + } + + return c.json({ + success: true, + message: 'Setup completed successfully', + }); + } catch (error) { + console.error('Failed to complete setup:', error); + return c.json( + { + error: 'Failed to complete setup', + }, + 500 + ); + } + }); + +export default app; diff --git a/api/routes/user.ts b/api/routes/user.ts new file mode 100644 index 0000000..688015b --- /dev/null +++ b/api/routes/user.ts @@ -0,0 +1,89 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import prisma from '../lib/db'; +import { checkAdmin } from '../middlewares/auth'; +import { updateUserSchema } from '../validations/user'; + +export const userRoute = new Hono() + .use(checkAdmin) + .get( + '/', + zValidator( + 'query', + z.object({ + page: z.coerce.number().min(1).default(1), + pageSize: z.coerce.number().min(1).max(100).default(10), + search: z.string().max(100).optional(), + }) + ), + async (c) => { + const { page, pageSize, search } = c.req.valid('query'); + const skip = (page - 1) * pageSize; + + const where = search + ? { + OR: [ + { username: { contains: search } }, + { email: { contains: search } }, + { name: { contains: search } }, + ], + } + : {}; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + username: true, + email: true, + role: true, + banned: true, + createdAt: true, + }, + }), + prisma.user.count({ where }), + ]); + + return c.json({ + users, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } + ) + .put( + '/:id', + zValidator('param', z.object({ id: z.string() })), + zValidator('json', updateUserSchema), + async (c) => { + const { id } = c.req.valid('param'); + const { username, email } = c.req.valid('json'); + + const data = { + ...(username && { username }), + ...(email && { email }), + }; + + const user = await prisma.user.update({ + where: { id }, + data, + select: { + id: true, + username: true, + email: true, + role: true, + banned: true, + createdAt: true, + }, + }); + + return c.json(user); + } + ); diff --git a/api/validations/account.ts b/api/validations/account.ts new file mode 100644 index 0000000..d4ec691 --- /dev/null +++ b/api/validations/account.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { passwordSchema } from './password'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const usernameSchema = z + .string() + .transform(sanitizeString) + .pipe( + z + .string() + .min(3, 'Username must be at least 3 characters') + .max(50, 'Username must be at most 50 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Username can only contain letters, numbers, underscores, and hyphens' + ) + ); + +export const updateAccountSchema = z.object({ + username: usernameSchema, + email: z.string().email('Invalid email address'), +}); + +export const updatePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: passwordSchema, + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); diff --git a/api/validations/instance.ts b/api/validations/instance.ts new file mode 100644 index 0000000..1ad85fd --- /dev/null +++ b/api/validations/instance.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const sanitizedString = (maxLength: number) => + z.string().transform(sanitizeString).pipe(z.string().max(maxLength)); + +// Max logo size: 512KB in base64 (which is ~683KB as base64 string) +const MAX_LOGO_BASE64_LENGTH = 700000; + +export const instanceSettingsSchema = z.object({ + instanceName: sanitizedString(100).optional(), + instanceDescription: sanitizedString(500).optional(), + instanceLogo: z + .string() + .max(MAX_LOGO_BASE64_LENGTH, 'Logo must be smaller than 512KB') + .refine( + (val) => { + if (!val || val === '') return true; + // Check if it's a valid base64 data URL for images + return /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp);base64,/.test(val); + }, + { message: 'Logo must be a valid image (PNG, JPEG, GIF, SVG, or WebP)' } + ) + .optional(), + allowRegistration: z.boolean().optional(), + requireEmailVerification: z.boolean().optional(), + maxSecretsPerUser: z.number().int().min(1).optional(), + defaultSecretExpiration: z.number().int().min(1).optional(), + maxSecretSize: z.number().int().min(1).optional(), + + allowPasswordProtection: z.boolean().optional(), + allowIpRestriction: z.boolean().optional(), + allowFileUploads: z.boolean().optional(), + maxPasswordAttempts: z.number().int().min(1).optional(), + sessionTimeout: z.number().int().min(1).optional(), + enableRateLimiting: z.boolean().optional(), + rateLimitRequests: z.number().int().min(1).optional(), + rateLimitWindow: z.number().int().min(1).optional(), + + // Organization features + requireInviteCode: z.boolean().optional(), + allowedEmailDomains: sanitizedString(500).optional(), + requireRegisteredUser: z.boolean().optional(), + disableEmailPasswordSignup: z.boolean().optional(), + + // Webhook notifications + webhookEnabled: z.boolean().optional(), + webhookUrl: z.string().url().optional().or(z.literal('')), + webhookSecret: sanitizedString(200).optional(), + webhookOnView: z.boolean().optional(), + webhookOnBurn: z.boolean().optional(), + + // Important message alert + importantMessage: sanitizedString(1000).optional(), + + // Prometheus metrics + metricsEnabled: z.boolean().optional(), + metricsSecret: sanitizedString(200).optional(), +}); diff --git a/api/validations/password.ts b/api/validations/password.ts new file mode 100644 index 0000000..7bd464a --- /dev/null +++ b/api/validations/password.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +/** + * Shared password strength rules. + * Used by Zod schemas and the better-auth sign-up hook. + */ +export const PASSWORD_RULES = { + minLength: 8, + patterns: [ + { regex: /[a-z]/, message: 'Password must contain at least one lowercase letter' }, + { regex: /[A-Z]/, message: 'Password must contain at least one uppercase letter' }, + { regex: /[0-9]/, message: 'Password must contain at least one number' }, + ], +} as const; + +/** + * Validates a password against strength rules. + * Returns the first error message found, or null if valid. + */ +export function validatePassword(password: string): string | null { + if (password.length < PASSWORD_RULES.minLength) { + return `Password must be at least ${PASSWORD_RULES.minLength} characters`; + } + + for (const { regex, message } of PASSWORD_RULES.patterns) { + if (!regex.test(password)) { + return message; + } + } + + return null; +} + +/** + * Zod schema for validating new password strength. + */ +export const passwordSchema = z + .string() + .min( + PASSWORD_RULES.minLength, + `Password must be at least ${PASSWORD_RULES.minLength} characters` + ) + .regex(PASSWORD_RULES.patterns[0].regex, PASSWORD_RULES.patterns[0].message) + .regex(PASSWORD_RULES.patterns[1].regex, PASSWORD_RULES.patterns[1].message) + .regex(PASSWORD_RULES.patterns[2].regex, PASSWORD_RULES.patterns[2].message); diff --git a/api/validations/secret-requests.ts b/api/validations/secret-requests.ts new file mode 100644 index 0000000..4dcb285 --- /dev/null +++ b/api/validations/secret-requests.ts @@ -0,0 +1,122 @@ +import isCidr from 'is-cidr'; +import { isIP } from 'is-ip'; +import { z } from 'zod'; +import { EXPIRATION_TIMES_SECONDS } from '../lib/constants'; + +// Valid durations for request validity (how long the creator link is active) +export const REQUEST_VALIDITY_SECONDS = [ + 2592000, // 30 days + 1209600, // 14 days + 604800, // 7 days + 259200, // 3 days + 86400, // 1 day + 43200, // 12 hours + 3600, // 1 hour +] as const; + +export const createSecretRequestSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(1000).optional(), + maxViews: z.number().int().min(1).max(9999).default(1), + expiresIn: z + .number() + .refine( + (val) => + EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]), + { + message: 'Invalid expiration time for secret', + } + ), + validFor: z + .number() + .refine( + (val) => + REQUEST_VALIDITY_SECONDS.includes(val as (typeof REQUEST_VALIDITY_SECONDS)[number]), + { + message: 'Invalid validity period for request', + } + ), + + allowedIp: z + .string() + .refine((val) => isCidr(val) || isIP(val), { + message: 'Must be a valid IPv4, IPv6, or CIDR', + }) + .nullable() + .optional(), + preventBurn: z.boolean().default(false), + webhookUrl: z.string().url().optional(), +}); + +export const secretRequestIdParamSchema = z.object({ + id: z.string().uuid(), +}); + +export const secretRequestTokenQuerySchema = z.object({ + token: z.string().length(64), +}); + +// Max encrypted secret size: 1MB (1,048,576 bytes) +const MAX_SECRET_SIZE = 1024 * 1024; +// Min encrypted secret size: 28 bytes (12 IV + 16 minimum ciphertext with auth tag) +const MIN_SECRET_SIZE = 28; +// Max encrypted title size: 1KB (1,024 bytes) +const MAX_TITLE_SIZE = 1024; + +export const submitSecretRequestSchema = z.object({ + secret: z + .preprocess((arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + return new Uint8Array(values as number[]); + } + return arg; + }, z.instanceof(Uint8Array)) + .refine((arr) => arr.length >= MIN_SECRET_SIZE, { + message: 'Secret data is too small to be valid encrypted content', + }) + .refine((arr) => arr.length <= MAX_SECRET_SIZE, { + message: `Secret exceeds maximum size of ${MAX_SECRET_SIZE} bytes`, + }), + title: z + .preprocess((arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + return new Uint8Array(values as number[]); + } + return arg; + }, z.instanceof(Uint8Array)) + .refine((arr) => arr.length <= MAX_TITLE_SIZE, { + message: `Title exceeds maximum size of ${MAX_TITLE_SIZE} bytes`, + }) + .optional() + .nullable(), + salt: z.string().min(16).max(64), +}); + +export const secretRequestsQuerySchema = z.object({ + page: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Page must be a positive integer string', + }), + limit: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Limit must be a positive integer string', + }), + status: z.enum(['all', 'pending', 'fulfilled', 'expired', 'cancelled']).optional(), +}); + +export const processSecretRequestsQueryParams = ( + query: z.infer +) => { + const page = query.page ? parseInt(query.page, 10) : undefined; + const limit = query.limit ? parseInt(query.limit, 10) : undefined; + const take = limit && limit > 0 && limit <= 100 ? limit : 10; + const skip = page && page > 0 ? (page - 1) * take : 0; + + return { skip, take, status: query.status }; +}; diff --git a/api/validations/secrets.ts b/api/validations/secrets.ts new file mode 100644 index 0000000..f56de84 --- /dev/null +++ b/api/validations/secrets.ts @@ -0,0 +1,120 @@ +import isCidr from 'is-cidr'; +import { isIP } from 'is-ip'; +import { z } from 'zod'; +import { EXPIRATION_TIMES_SECONDS } from '../lib/constants'; + +// Hard ceiling for encrypted payloads at parse time (prevents memory exhaustion). +// Configurable via env var in KB, defaults to 1024 KB (1MB). +const MAX_ENCRYPTED_PAYLOAD_KB = parseInt( + process.env.HEMMELIG_MAX_ENCRYPTED_PAYLOAD_SIZE || '1024', + 10 +); +export const MAX_ENCRYPTED_SIZE = MAX_ENCRYPTED_PAYLOAD_KB * 1024; + +// Schema for URL parameters (expecting string from URL) +export const secretsIdParamSchema = z.object({ + id: z.string(), +}); + +// Schema for query parameters (expecting strings from URL) +export const secretsQuerySchema = z.object({ + page: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Page must be a positive integer string', + }), + limit: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Limit must be a positive integer string', + }), +}); + +const jsonToUint8ArraySchema = z.preprocess( + (arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + if (values.length > MAX_ENCRYPTED_SIZE) { + return arg; // Let the refine below catch the size error + } + + return new Uint8Array(values); + } + + return arg; + }, + z.instanceof(Uint8Array).refine((arr) => arr.length <= MAX_ENCRYPTED_SIZE, { + message: `Encrypted payload exceeds maximum size of ${MAX_ENCRYPTED_PAYLOAD_KB} KB`, + }) +); + +const secretSchema = { + salt: z.string(), + secret: jsonToUint8ArraySchema, + title: jsonToUint8ArraySchema.optional().nullable(), + password: z.string().optional(), + expiresAt: z + .number() + .refine( + (val) => + EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]), + { + message: 'Invalid expiration time', + } + ), + views: z.number().int().min(1).max(9999).optional(), + isBurnable: z.boolean().default(true).optional(), + ipRange: z + .string() + .refine((val) => isCidr(val) || isIP(val), { + message: 'Must be a valid IPv4, IPv6, or CIDR', + }) + .nullable() + .optional(), + fileIds: z.array(z.string()).optional(), +}; + +export const createSecretsSchema = z.object(secretSchema); + +export const getSecretSchema = z.object({ + password: z.string().optional(), +}); + +const internalQueryParamsSchema = z.object({ + skip: z.number().int().min(0).optional(), + take: z.number().int().min(1).max(100).optional(), + page: z.number().int().min(1).optional(), + limit: z.number().int().min(1).max(100).optional(), +}); + +interface ProcessedSecretsQueryParams { + skip: number; + take: number; +} + +export const processSecretsQueryParams = ( + query: z.infer +): ProcessedSecretsQueryParams => { + const page = query.page ? parseInt(query.page, 10) : undefined; + const limit = query.limit ? parseInt(query.limit, 10) : undefined; + const take = limit && limit > 0 && limit <= 100 ? limit : 10; // Guaranteed number + const skip = page && page > 0 ? (page - 1) * take : 0; // Guaranteed number + + // Optional: Validate other params if needed, but we already have skip/take + const parseResult = internalQueryParamsSchema.safeParse({ + skip, + take, + page, + limit, + }); + + if (!parseResult.success) { + // Log error but return defaults for pagination + console.error('secrets query parameter processing error:', parseResult.error); + return { skip: 0, take: 10 }; + } + + return { skip, take }; +}; diff --git a/api/validations/user.ts b/api/validations/user.ts new file mode 100644 index 0000000..dd12d6b --- /dev/null +++ b/api/validations/user.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const usernameSchema = z + .string() + .transform(sanitizeString) + .pipe( + z + .string() + .min(3, 'Username must be at least 3 characters') + .max(50, 'Username must be at most 50 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Username can only contain letters, numbers, underscores, and hyphens' + ) + ); + +export const updateUserSchema = z.object({ + username: usernameSchema.optional(), + email: z.string().email().optional(), +}); diff --git a/banner.png b/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..9ece6e89896037ddfcc9de26cc391a235e13a358 GIT binary patch literal 10525 zcmch7S5#A77wriUX`+AvN(rcdBF#b-La`DMQ8cvB1XNn+HK9Z-s7MQh-qe7fKtNh3 zl3=4tf>An1C(?Uw_vHWnJH~yuZ?~KW24{@3_gZ`IRpy>MFK^$v!N()Y0{{RY!r-bY z0I*Ae|1WVJ0RL8ay!iI78!E-z~(%WRFf} zVArj%v!QF6%=O)nxzqKOu0L3bh~12eET#Zx`~Ul+gE^1VbDN%;a;;)Ys`#5i=H7bf zoNzHlOdE=xak;W>iDFpcw+yWi3?6*aYE^Tk3`Nf6F)>*hernPik%@n3ly++JiEO%2 z8|-`@5GY_$Y`ar&C}#t+d>o>8Us1*djzHtdiRINqdUx?^bjYvPKKc}1t!Tq@9)U4s z*x>VwYI-7s0H9U?2k3dPkO4;&!Y)q5g7IF4WWm(3ZcxhWS z<}(EVL=Qr^fHB8V1lC~d6;xvNj~O8e1>MXg28wQ<2Y}#H00cJ^V#+AOQyQfu~U~2_o3+*xk0IFDxrQeLnpiys)sCmsS+FQt6;83rNYD=>bv_m8Ivc z7!-U_Lb*v4hh%Bi39Vr9eWI#LqUs%OOU7;|YV0j`6F0+49r-B5%x^o|uFAXWmJ8?WXxYsJ!Yc~N3mb}x#YFipB}NK3gM{+= zqV++t#GE-9K3#DXH12a%NCDWWx}wOXAv-$s4Q+X;4_`IFyILZQ`>nO%HPLHUOjN^{ zr!_7jmh~{D@v0#iGLu(Z?I6HWaS*_rbZjc6q2Wx8o$C6?-)BvdqEWZOS2R=#9X8*LVQoP|PgZ8KvQa8kW z8d~1VVJ_FS@&-Lfe7XgyJFQ8LHI}itkLo?}C`ba(13-f$S3A_I#<`{CPlRZr!VABt zW*5|45~wFr9itB>^tO*$RPIhqif8!;ZodwnxU(h##GPl2%B!Tr*@|g)eS^Y8x?LNG zjoVe`k@BhOzqhhRb&u>P75GRDHX7C3WfW=J90A(-SYw<*QSrw-A2>R0d=~7gtZ1<; zOLx*4T<({xNTbZ0CX5Rgx>_G;PFJ)HeU?$o2b{SAu;m~_5DdFh;k zDH5Dz=YreSfdFHL>PHUcG*^kV7LcZcthx-gvWV5t?33${HhqKv1+<-#BD(Lb6QSl$ za{2eYP4xC=PNX+>Yo4$bB)*-+=W4HAuL(;pZ%O-fS@6&XHBy!!cN+i z?>y(5T3{}xBg5J%Ek`3^jRC=CU(S+deqUlrZ104y#ie8}KmcyABRTs$Q5X&xI7(l9 zjBLH6{=WLJBQj-;-0&jZl6D=A?DH7^)nlC{FG@|CIswGXv?=G8*c6E3+aWoJSU!|K zCeNNVURZqVf1yq$GT5u{`*Tb7Z}f35xMYXn+}43>I+_xeK?7m<15&%wA$34Io;51J zLUCBTO((4D=g!dU*6Et$;azD7*S7grTTG7%MGLC(->8!VMyit17Wac&***{nStzp;!A)-@6-~Bc5hC5I!~f+3Llf8QUj@6Q^+r2D@|%VHdi11xN))7s(F}* zKcKFoALogO15#|+C`^t z?v9!n)K%c+!k{Fh!^{Y+l#Ou4UTB4=&0yEcOv2)8=5F!GW?=iy(?)@NfZi8YXk7JZ zZNG~r8+y&p#y^RL%Va9LjAl%rOGvl1J;o!nWZDQT8I(ljoAEd2Bk)w!H2y;?)^CCs z%e^{6)rG# z!tNBpmkQyord=?mOItN`EQ@9*WDB}wVy{z z?^-cv$J+>fYW|f(F`0pfkQXLOSA_9}N`xwG=#OPs=c4vr$J55u)njFUGW#GoGOT6Y zD=XZzspxotEo`=b?OC;6I`nN_Y&%P#B*!t0sEbHD61=0*-&ybl#;lW?bfHgg-s?7v zzVM}^aOk|OycBSolNC!H@63Wfl$t-o3c%(p1mVNS11DV8OSg5eGbnP| zjg;@V2!6MT!QRckn0;zR#NdOgYQLAa)`MX4hWkac-aAp;<`!lj_f8@<{fs&PMKpc@ z88FZ6Bj>5~S2E2FdKJHB)GAK0$@gd#Kb{CuML`g*_w^oanFPy5oo-XM9N0>iUVX(3 zWd}}5fkD@_N4H(E;qFc_)rsWQDN0(&6xAHicytnRxZ z>4MFOBYhA+AT*+xqy4k61@yzayRt_cd35@tHnx^O;=zM+Wl}%7XjzTAaSK%@J+8R>jpdq z3S4EiEkc#ZkP)fJ_JuErfrOVMdap3D$kEO3hi#js`!M&K(brI&zJ*U_Z1)}#K6=Ef zwBncCyvGC87pfYprx)?xncDN=Wai?(U8@}blI+7jBHk+2{3{K=R&q7CD>EeZXjhio zz=(V`qhgRp+*byn)kBY!+5JWiv^h1oceXLSr|ixUdr}$xyFDFy8syWLz%xSo*+T~L zema`Wp>Lt-x>gKx84}Fphok}KT}yFnncm6g#gPkCZiK#xg zbS&@{i2p)Ptn^Lb?)RJRLbto zZHA}xfUJvA?0M^mjalwk31`G#MD@_<_+Jw~z~Cv^C*(C6%hs>H#6n<}xlU+zrEU$7 z8f8TT2|*}EwcozlqO1;+mM|zwG1iMR!?Dnv$^4tsFEF{$u5s7!U^mPS7hq}qnYdRlp#Y!(CN&1ea zO8cX<*YlDAG=9$2!M5fq2}^jQswy%R0`qs&v-j zMz?OUhklb1p6V0|-{N@PlPT{yM!Gk8>0|N8d2;J+ko3|n8=%cpWo)+*_oO=@N72a@*Q108}o%InlX07uI>>2WismHcorOs?i{7@o$h9cbP1FjUDCtogpy^Mt@7us(Vgz0p6bksY|e>is=7){a_nytl%V zzLXW)KXB7M9z^9$IaIEA<;?-3Ewuf!d^bT4n}T}6G`wLsBe@XbWLosLD-G7DT)ra+ z=s{SJ1Qd5RDS9c_nmk;X(KWuaI3>~e?c(=}VtZVc5a z1`8q|eQ{r5@*3S20FJZD4v$jdKR&B5M8t9J%I$}HpB^)7cuY^{)$YA_FZhXpFl%xi zIklWME0P;}lf_h6vG(39UzJ*nN5(_`d%zre_UhFwb8I5kIKLPwaDs9!Oe{sg)ihh$>%=MZKC{Cku%W?wQ`joLNPI#h?{*r1X)G79i!wI^Wi7J}8iK5&Z6^*+$mg ztQoiz9eFR~tYxd2j@n`E{dKb<_2^S1kr#(6;dAb>qshxN9`pTrtMb4ZVOC=p(KIsH z|3t)|(p_FNBX@RP#8W*`UoVr~_}0kW+WQd1ySqC30792rV_w`y&!h$Iz5SZ0s>EBR z4Cp-%Np6AU#AkYPH@ldLomCX7T5VdD(){7OeJA&^S%vGb&&xKq1LBC+_}iQWkr+@e(>n~@mI9?7oL8t~+=9PbOGeaydmqwyp`@Gb{inNOYfq$& zxisc!IMSUP*f5v1JmUUh?EyfFMc4S}s&M#9UUY}O_YiV`OmdSUC5=#H%Sk8~1^ExB za)w7D%{;o9n_ZP1qLN*x7(O7!9*kJ2AH9#^y8WH+PO}wnIvNq})aoAvI9x-F+&&_M z7V(;U2Dt{r@Hl&D)Gr4CdbgTD5^!!Xq5Ksyb^U;}t(H8hj9Q$#QyAvVz3d`!ab>pHZHfx+Lo@tMY3vrj7R8M-E`5Xn;gnxZR zA&?xNdZ9XngR+=xnR{8xDjj7bws`?ic_7E|@l*6}^>ICL3DEx3N~oXpWDF zeIRY4AK$w~CgocnRlB*}wniq&W!m=h=x$26f`_fkbX)1q)_U_dgXO?~^ zE8E2RWC#YDTmiSU-A5w?6V^n=L@#JFA0Y0sj=y8CK^kV@QIC82H`!hF6nEG3$Qsv| zMlJlI=wl%nF#dyyRkL>Cd>V9Q&(vseOhL%1>*opp&~$)u0C=s>7}Jdu>B$OMjHJ}0 zJ7W9VhC)B@#uxm+A9) zoA{Uzhepq1y<>$!#2@xGUe;E0ank!%ugdnRAlq#j6jwCB%rJQ9hS0*$I-C&8pd1)Y zdRN_QX#wuQP(+N4^seGp3)(r}EDSVbyNKv%h&eyhUskvt8OyZT1#k`e;5IgkPi;P$ zxGWDxHIK(<2zqy15&k=KWaFz{wAl0ogb`=fJbrNgpoV7^REbKPk!@!Ojce;y5AhgecEx$ zVPfZpVPF)1IQ_K^H=0{pbc%3%WW~Q!(^xf++H2ZVB?mt9(G-)AGc42f(C>YV^Ihjv zSl)g`T!b(|1@kqiVAfO?5IeM)f6pN7OuYIfVERq&C>gHFf}fcewMAvjmLtX+wMD!< z(xEyh!bPH^#GiuVBUt)@fj$lUuq5);8D&|GH8tMLfBsG!OEy`8PUB&%7uJ@-U0*HQ zmUX1iaK7dhAqhw}8pW1T&$7ycV_6LZkH8yNe{4VMCJ)v2&0M3FI&N7b>?(u^PyyZd z9VE`?7e^fx%CT6fq5cO7;f}v>zIG)ob^z|cHI{K-{V(9HfXF$Mxoi8P2r;aj98$!`#X1icKb9mn*Er`_OBC|fMS{O$Yb`% z0#Gi_grilzlWzTp+Uc3`M7}$adu-I$?O2e}&}IT#I$sO^M3~_1HYCejqD6582N%JTddb-U#wG6+t2Mc8NVm( zl9@3Pa-$$)yw9M7QP%0dTl|=r?J*v9#n18(?UY#2TB%jN0x5CaB0S?ARSp6IIxxq9xb?6>iY01?E{ZiTi`JNUz*r&!m-}gA-x! zg4%K0on&Lp&H?;$jv}3^qN%$I{5<|IiP?D}2S619Y}tA#tump?_5Emv&7;||8vEZA zoV#_uT7-W@n=5l<^qDh|!o+7jzP?URnZUp4^wh0#Z0@#_$XFg1S>T$Hcc}T|)X?WY@&GP^rO*3W$9xTzyZ+bh@wf93=2}UX=(^S004qt0 z)cz8D636R`GPFsbQhliQ{oFA5PhZ)AG!8ItiGhXVT2HNdBaS6kmLxVFYwa*fQDbEa6*dBi~7hfI) z<)@m(`Mn`F3-dq2{K*^XLNl2hP%fV^m+#j_8@2}|21n%P8UMr+5$ zTOudh+<1EDBd4IJOrO!2vy;tSju1eU3k1-Kd9V*(p)oFWz|xWWt8m4`blu9M-h5`~ z%50eJdhMQ`yikY>QRh@wUnpGiz5<~4gmpLMZcIKjPF3C|SPw0pLR3P>KwenDmmHi` z`Pv^_*OhhRIj^+DvHLpU)#ocP`%9T@xB=<5Xs=uencZuQ$)wYG!T@@@OIeyEI4mk@ zl{)M=0|vDs>)R#oY?-h@fnnEHE6z1+;S%yDsym<{T_F{IAUUBln-~yQ3;|e4say1I z9fr@lUNe%HWHQY5oSf*$S3Y|u@J7GKc)tr`Uj4c_sPcbieQH<4Y%oALev(U=c1t0B zyct*ioLMc_tyYkVkGa*;R?7%Z!d?#a1wP*Zzs}K-walu|Ffh0KX}C|r`#D7XIU&}U z^be=v&yevKWea#>vC$dbEge}ZA{>C*}}|R2i0W*7ND=jjr(F$uQIMc_uQ2TE5Y&ueMsCkcqhdL`hF&_ z&LU#VM4Dogqg6}Cq=v|wJ_-c%qF9?e;sd`D=7h}k_-ENhM{!b%d~Igpq=_hy)6GI@ zqu;EMA`|-Vknyf<2{Hw(sJ|B)RtEqKP=diVENby`gxNpVPqMZgD3jqMBU1{CIRR$@ zR>QQgw)c8YB*|pTyWAQ36pmylD=v8D`e%=?bAnfupi|B__sefF`wVK%Tz*^Ax~S2H ziObNts1N9wu?&|~XT-fz;P;mC7QyI57r6Qpp)lQ~74bSr0I&pASlmRri!r1U@0Uk> zR1YmrvY zRD?-vg4gm6oe&jqv;Y0V#Q6c7NUP8*yfO#i3jw+ge_l1EfT?`a zJ4&{6m$}17;t8Sk5zNgWQy+8@s2kfi5$uMcH$cOY2vOvNT1~Zy@1SoY_l`fx$h;^$ zsd=p`jNolbcR4(IdXAmZ`EzdWjf&)M%U%LDNp3Fe(v6r*WI*loiHq-j$GS;Z=8Ie4 zRago8^*b)l+Zs1u4I8nK<|cZaMrj5MSoH*NNKO;TJ8!G6EvXLLCJq=Oiy5)Ry|pDc zjgmntc|Eq9e&8IUEWKCa_9PPS@NGJwuIg+-`?)lgw}=q*5vB|9htbF z*H+*D3VR$~r&C6H7m5K_gF9HH%UWuy?ZklZdhrEGrH$hcm(O=Q&Ty}}KT*_}L7pe` z9(}$+^EDe~(~Dmj`2|>^delEw2VAa9zPtVve(6i)-(R!c`Rf)|kG%#@8m0aDRo1;Y z2laGYpZ&DET@ItA+6K@vPTJJXq&WNLAI)q>Lm$>ak9})3*T@&w>Snv%NWy3fIYNmu zu=jr8K3@8=I6(8R!3e9UvTIKIW z9_(tx9d|`V%C3CmiBpL?ONomq^UO$aM~K=6M1Qr>{iez1olCiScvflSjbXEbkHKgCLpGmbJ zY-lu?G}#Q{E^W{+j9A zy02>6kL}0Y%R4HySxZc!{w!Wa`_6UI@83G&`#7HR`F(ZrF-TKlR=!n7oRiS~82Z_} zFUPljlm-_CcU z{@t>qeJD0v;NQA=M<*(d{)Met+V^(#()c&11-t9L9YgpWIL%vgL3W%1n1oGtdqwUM z$R`#*gc1#W(;iOWOJDMy{!XR5uwYXXXXT-r1WYgVAXvgX~1K*yj ze4AER<0s0qNgUAVahlt-bL>`667u`m0lSJ=l7`?t zY5aLpdA3*5uFf;6J9OR`Hb8M-PjE|sBT%*mNzv~Oe64Dd+x1Rf=l)}QS&LVz7bKkKunx@de;vapp}b_7X0JvOQtc7r@KTX^ z@Ayl>ksep_&9c-RN@Q~Bd7px5tX(<^cERdW5u5dhOGTu2aPL2tLEkm{^1eoyJ!7Ly zdTkF*!b1x@$9+H{P!5rgtj2mwJI}jLJ7-&t##su#ma%>80=~ExNFpMVk3*KVTg?8+ zyO*>VtvWX&;OOHy!UenmJK67Vx3PC>FEmHJrJVWXi8&nrrpYst!u+p4T)@2boe2Yf z0`poMbLBCJQ<3Gt1PUKPaEgOD@rq4`!smOLJ*=Mm> z@)LD?b?-O_Vf+jIX$$h0NJ@9HE=b~0eJxktb{xSLnSS(o5FHGA^l~>r@v~^}FXPD9 zTG;O?utq5E?VH0~xSBJRbA9Tg-)6+7n}yadv|TbQn?3vZB<%ybAWc}V?$o5}08dlr z1Jn;|DbuhfHk9`f8_wT0J}{*eW6vfqV4 z+N?r;(1e!UDn~zCx|0uFyWt5qJFvQ`!&&Izm#xi7#~;8EZ_(atz|q5CbCt?7T^Q=P zSFCV9<3cOYKE`4lKfB5D=Eklc2~o3mEKjNl(SaOj96KBEq6I3DYg%g7;ra?HB59-) zNYQXF=(L|E=x2)n&JZvRaeuA5=E3U!k}T8;JvMq4*(&L%G1}-~SIE<6De< Yc2`16%&y` | Set a title for the secret | +| `-p, --password ` | Protect with a password | +| `-e, --expires