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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 09:30:19 +01:00
commit bc9f96cbd4
268 changed files with 45773 additions and 0 deletions

56
.dockerignore Normal file
View File

@@ -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

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*]
indent_style = space
indent_size = 4
[*.json]
indent_size = 2
[*.yml]
indent_size = 2

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @bjarneo

25
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -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

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

26
.github/ISSUE_TEMPLATE/docs.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: 📄 Documentation issue
description: Found an issue in the documentation?
title: "[DOCS] <description>"
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

View File

@@ -0,0 +1,27 @@
name: 💡Feature Request
description: Have a new idea/feature? Please suggest!
title: "[FEATURE] <description>"
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

22
.github/ISSUE_TEMPLATE/other.yml vendored Normal file
View File

@@ -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

168
.github/workflows/cli-release.yml vendored Normal file
View File

@@ -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.

View File

@@ -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

108
.github/workflows/release.yml vendored Normal file
View File

@@ -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<<EOF"
echo "$COMMITS"
echo "EOF"
} >> $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

51
.github/workflows/trivy.yaml vendored Normal file
View File

@@ -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'

139
.gitignore vendored Normal file
View File

@@ -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/

1
.husky/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
npx lint-staged

9
.npmignore Normal file
View File

@@ -0,0 +1,9 @@
src/client
src/server
src/*.js
server.js
config/
public/
.github/
tests/
hemmelig.backup.db

11
.prettierignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
build
.husky
bun.lock
package-lock.json
*.min.js
*.min.css
prisma/migrations
.github
helm/

10
.prettierrc Normal file
View File

@@ -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"]
}

640
CLAUDE.md Normal file
View File

@@ -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<Type>(initialValue);
const handleAction = () => {
// Event handler logic
};
return <div className="bg-white dark:bg-dark-800">{/* Always support light/dark mode */}</div>;
}
```
### 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<ExampleState>((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: <SecretsPage />,
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
<p>{t('secret_page.loading_message')}</p>
// BAD: Hardcoded string
<p>Loading...</p>
```
### 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._

72
Dockerfile Normal file
View File

@@ -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"]

8
LICENSE Normal file
View File

@@ -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.

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
<div align="center">
<img src="banner.png" alt="hemmelig" />
</div>
<h1 align="center">Hemmelig - Encrypted Secret Sharing</h1>
<p align="center">
Share sensitive information securely with client-side encryption and self-destructing messages.
</p>
<p align="center">
<a href="https://hemmelig.app">Try it online</a> •
<a href="https://terces.cloud">Deploy to terces.cloud</a> •
<a href="#quick-start">Quick Start</a> •
<a href="docs/docker.md">Docker Guide</a> •
<a href="docs/env.md">Configuration</a>
</p>
<p align="center">
<a href="https://hub.docker.com/r/hemmeligapp/hemmelig"><img src="https://img.shields.io/docker/pulls/hemmeligapp/hemmelig" alt="Docker pulls" /></a>
<a href="https://terces.cloud"><img src="https://img.shields.io/badge/Deploy%20to-terces.cloud-269B91" alt="Deploy to terces.cloud" /></a>
<a href="https://ko-fi.com/bjarneoeverli"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Ko--fi-FF5E5B?logo=ko-fi&logoColor=white" alt="Buy Me a Coffee" /></a>
</p>
## 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.
<a href="https://terces.cloud"><img src="https://img.shields.io/badge/Deploy%20to-terces.cloud-269B91?style=for-the-badge" alt="Deploy to terces.cloud" /></a>
## 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.

158
api/app.ts Normal file
View File

@@ -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<string[]>('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);
});

176
api/auth.ts Normal file
View File

@@ -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];
};

255
api/config.ts Normal file
View File

@@ -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<string, SocialProviderConfig> = {};
// 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<T>(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,
};

67
api/jobs/expired.ts Normal file
View File

@@ -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);
}
};

14
api/jobs/index.ts Normal file
View File

@@ -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();
});
}

62
api/lib/analytics.ts Normal file
View File

@@ -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));
}

85
api/lib/constants.ts Normal file
View File

@@ -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;

19
api/lib/db.ts Normal file
View File

@@ -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<typeof prismaClientSingleton>;
}
const db = globalThis.prisma ?? prismaClientSingleton();
export default db;
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db;

77
api/lib/files.ts Normal file
View File

@@ -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<void> {
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();

49
api/lib/password.ts Normal file
View File

@@ -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<string> {
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<boolean> {
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;
}
}

40
api/lib/settings.ts Normal file
View File

@@ -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;

130
api/lib/utils.ts Normal file
View File

@@ -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<true> if URL is safe (not internal), Promise<false> if it's a private/internal address
*/
export const isPublicUrl = async (url: string): Promise<boolean> => {
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;
}
};

106
api/lib/webhook.ts Normal file
View File

@@ -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<string, string>,
body: string
): Promise<void> {
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<string, string> = {
'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);
}
})();
}

91
api/middlewares/auth.ts Normal file
View File

@@ -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<Env>(async (c, next) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});
export const checkAdmin = createMiddleware<Env>(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<Env>(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);
}
});

View File

@@ -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();
};

View File

@@ -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<typeof rateLimiter> | 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;

1568
api/openapi.ts Normal file

File diff suppressed because it is too large Load Diff

48
api/routes.ts Normal file
View File

@@ -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;

130
api/routes/account.ts Normal file
View File

@@ -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;

253
api/routes/analytics.ts Normal file
View File

@@ -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<string, { date: string; secrets: number; views: number }>
);
// 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;

156
api/routes/api-keys.ts Normal file
View File

@@ -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 };

134
api/routes/files.ts Normal file
View File

@@ -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;

131
api/routes/health.ts Normal file
View File

@@ -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<CheckResult> {
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<CheckResult> {
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;

169
api/routes/instance.ts Normal file
View File

@@ -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;

151
api/routes/invites.ts Normal file
View File

@@ -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);
}
});

159
api/routes/metrics.ts Normal file
View File

@@ -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;

View File

@@ -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<void> {
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<string, string> = {
'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;

343
api/routes/secrets.ts Normal file
View File

@@ -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;

82
api/routes/setup.ts Normal file
View File

@@ -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;

89
api/routes/user.ts Normal file
View File

@@ -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);
}
);

View File

@@ -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'],
});

View File

@@ -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(),
});

View File

@@ -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);

View File

@@ -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<typeof secretRequestsQuerySchema>
) => {
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 };
};

120
api/validations/secrets.ts Normal file
View File

@@ -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<typeof secretsQuerySchema>
): 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 };
};

22
api/validations/user.ts Normal file
View File

@@ -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(),
});

BIN
banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

7
cli-go/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Binary
hemmelig
hemmelig-*
# IDE
.idea/
.vscode/

126
cli-go/README.md Normal file
View File

@@ -0,0 +1,126 @@
# Hemmelig CLI (Go)
A standalone Go binary for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app).
```
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|___/
```
## Installation
### Download Binary
Download the pre-built binary for your platform from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true).
Replace `VERSION` below with the desired version (e.g., `1.0.0`):
#### Linux (amd64)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-amd64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### Linux (arm64)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-arm64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### macOS (Apple Silicon)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-arm64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### macOS (Intel)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-amd64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### Windows (PowerShell)
```powershell
$VERSION = "1.0.1"
Invoke-WebRequest -Uri "https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v$VERSION/hemmelig-windows-amd64.exe" -OutFile "hemmelig.exe"
# Move to a directory in your PATH, e.g.:
Move-Item hemmelig.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\hemmelig.exe"
```
#### Verify Download
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/checksums.txt -o checksums.txt
sha256sum -c checksums.txt --ignore-missing
```
### Build from Source
```bash
go build -o hemmelig .
```
## Usage
```bash
# Create a simple secret
hemmelig "my secret message"
# With a title and custom expiration
hemmelig "API key: sk-1234" -t "Production API Key" -e 7d
# Password protected
hemmelig "sensitive data" -p "mypassword"
# Multiple views allowed
hemmelig "shared config" -v 5
# Pipe from stdin
cat config.json | hemmelig -t "Config file"
# Use a self-hosted instance
hemmelig "internal secret" -u https://secrets.company.com
```
## Options
| Option | Description |
| ----------------------- | ------------------------------------------------------ |
| `-t, --title <title>` | Set a title for the secret |
| `-p, --password <pass>` | Protect with a password |
| `-e, --expires <time>` | Expiration: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d |
| `-v, --views <number>` | Max views (1-9999, default: 1) |
| `-b, --burnable` | Burn after first view (default) |
| `--no-burnable` | Don't burn until all views used |
| `-u, --url <url>` | Base URL (default: https://hemmelig.app) |
| `-h, --help, /?` | Show help |
| `--version` | Show version |
## Security
- All encryption happens locally using AES-256-GCM
- Keys are derived using PBKDF2 with 600,000 iterations
- The decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server
- The server only stores encrypted data
## License
MIT

5
cli-go/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/HemmeligOrg/hemmelig-cli
go 1.24.0
require golang.org/x/crypto v0.45.0

2
cli-go/go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=

343
cli-go/main.go Normal file
View File

@@ -0,0 +1,343 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"golang.org/x/crypto/pbkdf2"
)
const version = "1.0.2"
var expirationTimes = map[string]int{
"5m": 300,
"30m": 1800,
"1h": 3600,
"4h": 14400,
"12h": 43200,
"1d": 86400,
"3d": 259200,
"7d": 604800,
"14d": 1209600,
"28d": 2419200,
}
type Options struct {
Secret string
Title string
Password string
Expires string
Views int
Burnable bool
BaseURL string
}
type SecretResponse struct {
ID string `json:"id"`
Error string `json:"error,omitempty"`
}
func generateKey() string {
b := make([]byte, 24)
rand.Read(b)
encoded := base64.URLEncoding.EncodeToString(b)
if len(encoded) > 32 {
return encoded[:32]
}
return encoded
}
func generateSalt() string {
return generateKey()
}
func deriveKey(password, salt string) []byte {
return pbkdf2.Key([]byte(password), []byte(salt), 1300000, 32, sha256.New)
}
func encrypt(data []byte, encryptionKey, salt string) ([]byte, error) {
key := deriveKey(encryptionKey, salt)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
iv := make([]byte, 12)
if _, err := rand.Read(iv); err != nil {
return nil, err
}
ciphertext := aesGCM.Seal(nil, iv, data, nil)
// Format: IV (12 bytes) + ciphertext (includes auth tag)
result := make([]byte, len(iv)+len(ciphertext))
copy(result, iv)
copy(result[len(iv):], ciphertext)
return result, nil
}
func uint8ArrayToObject(data []byte) map[string]int {
obj := make(map[string]int)
for i, b := range data {
obj[strconv.Itoa(i)] = int(b)
}
return obj
}
func createSecret(opts Options) (string, error) {
encryptionKey := opts.Password
if encryptionKey == "" {
encryptionKey = generateKey()
}
salt := generateSalt()
encryptedSecret, err := encrypt([]byte(opts.Secret), encryptionKey, salt)
if err != nil {
return "", fmt.Errorf("failed to encrypt secret: %w", err)
}
payload := map[string]interface{}{
"secret": uint8ArrayToObject(encryptedSecret),
"salt": salt,
"expiresAt": expirationTimes[opts.Expires],
"views": opts.Views,
"isBurnable": opts.Burnable,
}
if opts.Title != "" {
encryptedTitle, err := encrypt([]byte(opts.Title), encryptionKey, salt)
if err != nil {
return "", fmt.Errorf("failed to encrypt title: %w", err)
}
payload["title"] = uint8ArrayToObject(encryptedTitle)
}
if opts.Password != "" {
payload["password"] = opts.Password
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
resp, err := http.Post(
opts.BaseURL+"/api/secrets",
"application/json",
bytes.NewBuffer(jsonData),
)
if err != nil {
return "", fmt.Errorf("failed to create secret: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var result SecretResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if resp.StatusCode != http.StatusCreated {
errMsg := result.Error
if errMsg == "" {
errMsg = "Unknown error"
}
return "", fmt.Errorf("failed to create secret: %s", errMsg)
}
var url string
if opts.Password != "" {
url = fmt.Sprintf("%s/secret/%s", opts.BaseURL, result.ID)
} else {
url = fmt.Sprintf("%s/secret/%s#decryptionKey=%s", opts.BaseURL, result.ID, encryptionKey)
}
return url, nil
}
func printHelp() {
fmt.Print(`
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \ '_ ` + "`" + ` _ \| '_ ` + "`" + ` _ \ / _ \ | |/ _` + "`" + ` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|___/
Create encrypted secrets from the command line
Usage:
hemmelig <secret> [options]
echo "secret" | hemmelig [options]
hemmelig --help
Options:
-t, --title <title> Set a title for the secret
-p, --password <pass> Protect with a password (if not set, key is in URL)
-e, --expires <time> Expiration time (default: 1d)
Valid: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d
-v, --views <number> Max views before deletion (default: 1, max: 9999)
-b, --burnable Burn after first view (default: true)
--no-burnable Don't burn after first view
-u, --url <url> Base URL (default: https://hemmelig.app)
-h, --help, /? Show this help message
--version Show version number
Examples:
# Create a simple secret
hemmelig "my secret message"
# Create a secret with a title and 7-day expiration
hemmelig "my secret" -t "API Key" -e 7d
# Create a password-protected secret
hemmelig "my secret" -p "mypassword123"
# Create a secret with 5 views allowed
hemmelig "my secret" -v 5
# Pipe content from a file
cat ~/.ssh/id_rsa.pub | hemmelig -t "SSH Public Key"
# Use a self-hosted instance
hemmelig "my secret" -u https://secrets.mycompany.com
`)
}
func readStdin() string {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
return ""
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return ""
}
return strings.TrimRight(string(data), "\n\r")
}
func parseArgs(args []string) (Options, bool, bool) {
opts := Options{
Expires: "1d",
Views: 1,
Burnable: true,
BaseURL: "https://hemmelig.app",
}
showHelp := false
showVersion := false
for i := 0; i < len(args); i++ {
arg := args[i]
switch arg {
case "-h", "--help", "/?":
showHelp = true
case "--version":
showVersion = true
case "-t", "--title":
if i+1 < len(args) {
i++
opts.Title = args[i]
}
case "-p", "--password":
if i+1 < len(args) {
i++
opts.Password = args[i]
}
case "-e", "--expires":
if i+1 < len(args) {
i++
opts.Expires = args[i]
}
case "-v", "--views":
if i+1 < len(args) {
i++
v, err := strconv.Atoi(args[i])
if err == nil {
opts.Views = v
}
}
case "-b", "--burnable":
opts.Burnable = true
case "--no-burnable":
opts.Burnable = false
case "-u", "--url":
if i+1 < len(args) {
i++
opts.BaseURL = args[i]
}
default:
if !strings.HasPrefix(arg, "-") && opts.Secret == "" {
opts.Secret = arg
}
}
}
return opts, showHelp, showVersion
}
func main() {
opts, showHelp, showVersion := parseArgs(os.Args[1:])
if showVersion {
fmt.Println(version)
os.Exit(0)
}
if showHelp {
printHelp()
os.Exit(0)
}
if opts.Secret == "" {
opts.Secret = readStdin()
}
if opts.Secret == "" {
fmt.Fprintln(os.Stderr, "Error: No secret provided. Use --help for usage information.")
os.Exit(1)
}
if _, ok := expirationTimes[opts.Expires]; !ok {
fmt.Fprintf(os.Stderr, "Error: Invalid expiration time \"%s\".\n", opts.Expires)
fmt.Fprintln(os.Stderr, "Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d")
os.Exit(1)
}
if opts.Views < 1 || opts.Views > 9999 {
fmt.Fprintln(os.Stderr, "Error: Views must be between 1 and 9999.")
os.Exit(1)
}
url, err := createSecret(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(url)
}

146
cli/README.md Normal file
View File

@@ -0,0 +1,146 @@
# hemmelig
CLI and library for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app).
```
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|___/
```
## Features
- **Client-side AES-256-GCM encryption** - Your secrets are encrypted before leaving your machine
- **Zero-knowledge** - The server never sees your plaintext secrets
- **Self-destructing** - Secrets auto-delete after views or expiration
- **Password protection** - Optional additional security layer
- **Works with any Hemmelig instance** - Use hemmelig.app or self-hosted
## Installation
```bash
npm install -g hemmelig
```
Or use with npx:
```bash
npx hemmelig "my secret"
```
## CLI Usage
```bash
# Create a simple secret
hemmelig "my secret message"
# With a title and custom expiration
hemmelig "API key: sk-1234" -t "Production API Key" -e 7d
# Password protected
hemmelig "sensitive data" -p "mypassword"
# Multiple views allowed
hemmelig "shared config" -v 5
# Pipe from stdin
cat config.json | hemmelig -t "Config file"
echo "my secret" | hemmelig
# Use a self-hosted instance
hemmelig "internal secret" -u https://secrets.company.com
```
### Options
| Option | Description |
| ----------------------- | ------------------------------------------------------ |
| `-t, --title <title>` | Set a title for the secret |
| `-p, --password <pass>` | Protect with a password |
| `-e, --expires <time>` | Expiration: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d |
| `-v, --views <number>` | Max views (1-9999, default: 1) |
| `-b, --burnable` | Burn after first view (default) |
| `--no-burnable` | Don't burn until all views used |
| `-u, --url <url>` | Base URL (default: https://hemmelig.app) |
| `-h, --help` | Show help |
| `--version` | Show version |
## Library Usage
```typescript
import { createSecret } from 'hemmelig';
const result = await createSecret({
secret: 'my secret message',
title: 'API Key',
expiresIn: '1h',
views: 1,
burnable: true,
baseUrl: 'https://hemmelig.app', // optional
});
console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=...
console.log(result.id); // abc123
```
### API
#### `createSecret(options: SecretOptions): Promise<CreateSecretResult>`
Creates an encrypted secret on a Hemmelig server.
**Options:**
| Property | Type | Default | Description |
| ----------- | --------------- | ------------------------ | ----------------------------- |
| `secret` | `string` | required | The secret content to encrypt |
| `title` | `string` | - | Optional title |
| `password` | `string` | - | Password protection |
| `expiresIn` | `ExpirationKey` | `'1d'` | Expiration time |
| `views` | `number` | `1` | Max views (1-9999) |
| `burnable` | `boolean` | `true` | Burn on first view |
| `baseUrl` | `string` | `'https://hemmelig.app'` | Server URL |
**Returns:**
| Property | Type | Description |
| ----------- | -------- | ----------------------------- |
| `url` | `string` | Full URL to access the secret |
| `id` | `string` | The secret ID |
| `expiresIn` | `string` | The expiration time set |
## CI/CD Integration
### GitHub Actions
```yaml
- name: Share deployment credentials
run: |
SECRET_URL=$(npx hemmelig "${{ secrets.DEPLOY_KEY }}" \
-t "Deployment Key" \
-e 1h)
echo "Secret URL: $SECRET_URL"
```
### GitLab CI
```yaml
share-secret:
script:
- SECRET_URL=$(npx hemmelig "$DB_PASSWORD" -e 4h)
- echo "Secret URL: $SECRET_URL"
```
## Security
- All encryption happens locally using AES-256-GCM
- Keys are derived using PBKDF2 with 600,000 iterations
- The decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server
- The server only stores encrypted data
## License
MIT

54
cli/package-lock.json generated Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "hemmelig",
"version": "7.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hemmelig",
"version": "7.0.0",
"license": "MIT",
"bin": {
"hemmelig": "dist/bin.js"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "20.19.26",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz",
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

54
cli/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "hemmelig",
"version": "7.0.0",
"description": "CLI for creating encrypted, self-destructing secrets via Hemmelig",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"hemmelig": "dist/bin.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": [
"hemmelig",
"secret",
"secrets",
"encryption",
"cli",
"security",
"privacy",
"self-destruct",
"one-time",
"share"
],
"author": "Hemmelig",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/HemmeligOrg/Hemmelig.app.git",
"directory": "cli"
},
"bugs": {
"url": "https://github.com/HemmeligOrg/Hemmelig.app/issues"
},
"homepage": "https://hemmelig.app",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.8.3"
}
}

197
cli/src/bin.ts Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
import { createSecret, EXPIRATION_TIMES, type ExpirationKey, type SecretOptions } from './index.js';
const VERSION = '1.0.0';
/**
* Prints help message
*/
function printHelp(): void {
console.log(`
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \\ '_ \` _ \\| '_ \` _ \\ / _ \\ | |/ _\` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\\___|_| |_| |_|_| |_| |_|\\___|_|_|\\__, |
|___/
Create encrypted secrets from the command line
Usage:
hemmelig <secret> [options]
echo "secret" | hemmelig [options]
hemmelig --help
Options:
-t, --title <title> Set a title for the secret
-p, --password <pass> Protect with a password (if not set, key is in URL)
-e, --expires <time> Expiration time (default: 1d)
Valid: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d
-v, --views <number> Max views before deletion (default: 1, max: 9999)
-b, --burnable Burn after first view (default: true)
--no-burnable Don't burn after first view
-u, --url <url> Base URL (default: https://hemmelig.app)
-h, --help Show this help message
--version Show version number
Examples:
# Create a simple secret
hemmelig "my secret message"
# Create a secret with a title and 7-day expiration
hemmelig "my secret" -t "API Key" -e 7d
# Create a password-protected secret
hemmelig "my secret" -p "mypassword123"
# Create a secret with 5 views allowed
hemmelig "my secret" -v 5
# Pipe content from a file
cat ~/.ssh/id_rsa.pub | hemmelig -t "SSH Public Key"
# Use a self-hosted instance
hemmelig "my secret" -u https://secrets.mycompany.com
`);
}
/**
* Parses command line arguments
*/
function parseArgs(args: string[]): SecretOptions & { help?: boolean; version?: boolean } {
const options: SecretOptions & { help?: boolean; version?: boolean } = {
secret: '',
};
let i = 0;
while (i < args.length) {
const arg = args[i];
switch (arg) {
case '-h':
case '--help':
options.help = true;
break;
case '--version':
options.version = true;
break;
case '-t':
case '--title':
options.title = args[++i];
break;
case '-p':
case '--password':
options.password = args[++i];
break;
case '-e':
case '--expires':
options.expiresIn = args[++i] as ExpirationKey;
break;
case '-v':
case '--views':
options.views = parseInt(args[++i], 10);
break;
case '-b':
case '--burnable':
options.burnable = true;
break;
case '--no-burnable':
options.burnable = false;
break;
case '-u':
case '--url':
options.baseUrl = args[++i];
break;
default:
// If it doesn't start with -, it's the secret
if (!arg.startsWith('-') && !options.secret) {
options.secret = arg;
}
break;
}
i++;
}
return options;
}
/**
* Reads from stdin if available
*/
async function readStdin(): Promise<string> {
return new Promise((resolve) => {
// Check if stdin is a TTY (interactive terminal)
if (process.stdin.isTTY) {
resolve('');
return;
}
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
// Only trim trailing whitespace to preserve internal formatting
resolve(data.trimEnd());
});
// Timeout after 100ms if no data
setTimeout(() => {
if (!data) {
resolve('');
}
}, 100);
});
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
const options = parseArgs(args);
if (options.version) {
console.log(VERSION);
process.exit(0);
}
if (options.help) {
printHelp();
process.exit(0);
}
// Try to read from stdin if no secret provided
if (!options.secret) {
options.secret = await readStdin();
}
if (!options.secret) {
console.error('Error: No secret provided. Use --help for usage information.');
process.exit(1);
}
// Validate expiration time
if (options.expiresIn && !(options.expiresIn in EXPIRATION_TIMES)) {
console.error(`Error: Invalid expiration time "${options.expiresIn}".`);
console.error('Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d');
process.exit(1);
}
// Validate views
if (options.views !== undefined && (options.views < 1 || options.views > 9999)) {
console.error('Error: Views must be between 1 and 9999.');
process.exit(1);
}
try {
const result = await createSecret(options);
console.log(result.url);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exit(1);
}
}
main();

210
cli/src/index.ts Normal file
View File

@@ -0,0 +1,210 @@
import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
/**
* Valid expiration times in seconds
*/
export const EXPIRATION_TIMES = {
'5m': 300,
'30m': 1800,
'1h': 3600,
'4h': 14400,
'12h': 43200,
'1d': 86400,
'3d': 259200,
'7d': 604800,
'14d': 1209600,
'28d': 2419200,
} as const;
export type ExpirationKey = keyof typeof EXPIRATION_TIMES;
/**
* Options for creating a secret
*/
export interface SecretOptions {
/** The secret content to encrypt */
secret: string;
/** Optional title for the secret */
title?: string;
/** Optional password protection */
password?: string;
/** Expiration time (default: '1d') */
expiresIn?: ExpirationKey;
/** Maximum number of views (default: 1, max: 9999) */
views?: number;
/** Whether to burn after first view (default: true) */
burnable?: boolean;
/** Base URL of the Hemmelig instance (default: 'https://hemmelig.app') */
baseUrl?: string;
}
/**
* Result from creating a secret
*/
export interface CreateSecretResult {
/** The full URL to access the secret */
url: string;
/** The secret ID */
id: string;
/** The expiration time that was set */
expiresIn: string;
}
/**
* Generates a random 32-character string using URL-safe base64 encoding
*/
function generateKey(): string {
return randomBytes(24).toString('base64url').slice(0, 32);
}
/**
* Generates a random 32-character salt
*/
function generateSalt(): string {
return randomBytes(24).toString('base64url').slice(0, 32);
}
/**
* Derives a 256-bit AES key using PBKDF2-SHA256
*/
function deriveKey(password: string, salt: string): Buffer {
return pbkdf2Sync(password, salt, 1300000, 32, 'sha256');
}
/**
* Encrypts data using AES-256-GCM
* Returns IV (12 bytes) + ciphertext + auth tag (16 bytes)
*/
function encrypt(data: Buffer, encryptionKey: string, salt: string): Uint8Array {
const key = deriveKey(encryptionKey, salt);
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
const authTag = cipher.getAuthTag();
// Format: IV (12 bytes) + ciphertext + authTag (16 bytes)
const fullMessage = new Uint8Array(iv.length + encrypted.length + authTag.length);
fullMessage.set(iv, 0);
fullMessage.set(encrypted, iv.length);
fullMessage.set(authTag, iv.length + encrypted.length);
return fullMessage;
}
/**
* Encrypts text using AES-256-GCM
*/
function encryptText(text: string, encryptionKey: string, salt: string): Uint8Array {
return encrypt(Buffer.from(text, 'utf8'), encryptionKey, salt);
}
/**
* Converts Uint8Array to a JSON-serializable object format
* This matches the format expected by the API's jsonToUint8ArraySchema
*/
function uint8ArrayToObject(arr: Uint8Array): Record<string, number> {
const obj: Record<string, number> = {};
for (let i = 0; i < arr.length; i++) {
obj[i.toString()] = arr[i];
}
return obj;
}
/**
* Creates an encrypted secret on a Hemmelig server
*
* @example
* ```typescript
* import { createSecret } from 'hemmelig';
*
* const result = await createSecret({
* secret: 'my secret message',
* title: 'API Key',
* expiresIn: '1h',
* views: 1
* });
*
* console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=...
* ```
*/
export async function createSecret(options: SecretOptions): Promise<CreateSecretResult> {
const {
secret,
title,
password,
expiresIn = '1d',
views = 1,
burnable = true,
baseUrl = 'https://hemmelig.app',
} = options;
// Validate expiration time
if (!(expiresIn in EXPIRATION_TIMES)) {
throw new Error(
`Invalid expiration time "${expiresIn}". Valid options: ${Object.keys(EXPIRATION_TIMES).join(', ')}`
);
}
// Validate views
if (views < 1 || views > 9999) {
throw new Error('Views must be between 1 and 9999');
}
// Generate encryption key and salt
const encryptionKey = password || generateKey();
const salt = generateSalt();
// Encrypt the secret (and title if provided)
const encryptedSecret = encryptText(secret, encryptionKey, salt);
const encryptedTitle = title ? encryptText(title, encryptionKey, salt) : null;
// Prepare the request payload
const payload: Record<string, unknown> = {
secret: uint8ArrayToObject(encryptedSecret),
salt,
expiresAt: EXPIRATION_TIMES[expiresIn],
views,
isBurnable: burnable,
};
if (encryptedTitle) {
payload.title = uint8ArrayToObject(encryptedTitle);
}
// If password is provided, send it for server-side hashing
// Otherwise, leave it empty (key will be in URL fragment)
if (password) {
payload.password = password;
}
// Make the API request
const response = await fetch(`${baseUrl}/api/secrets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
};
throw new Error(`Failed to create secret: ${errorData.error || response.statusText}`);
}
const data = (await response.json()) as { id: string };
// Construct the URL
// If no password was provided, include the decryption key in the URL fragment
const url = password
? `${baseUrl}/secret/${data.id}`
: `${baseUrl}/secret/${data.id}#decryptionKey=${encryptionKey}`;
return {
url,
id: data.id,
expiresIn,
};
}

19
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
hemmelig:
image: hemmeligapp/hemmelig:v7
container_name: hemmelig
restart: unless-stopped
volumes:
- ./database:/app/database
- ./uploads:/app/uploads
environment:
- DATABASE_URL=file:/app/database/hemmelig.db
- BETTER_AUTH_SECRET=change-this-to-a-secure-secret-min-32-chars
- BETTER_AUTH_URL=https://secrets.example.com
- NODE_ENV=production
- HEMMELIG_BASE_URL=https://secrets.example.com
ports:
- '3000:3000'
healthcheck:
test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:3000/api/health/ready',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s

106
docs/api.md Normal file
View File

@@ -0,0 +1,106 @@
# API Documentation
Hemmelig provides a REST API for programmatic access to secret sharing functionality.
## Interactive Documentation
The API is documented using OpenAPI 3.0 specification with an interactive Swagger UI:
- **Swagger UI:** `/api/docs` - Interactive API explorer
- **OpenAPI Spec:** `/api/openapi.json` - Raw OpenAPI specification
## Authentication
### Session Authentication
For browser-based access, authenticate through the `/auth` endpoints provided by better-auth. Session cookies are automatically managed.
### API Key Authentication
For programmatic access, create an API key in your account settings under the **Developer** tab.
Use the API key as a Bearer token in the `Authorization` header:
```bash
curl -H "Authorization: Bearer hemmelig_your_api_key_here" \
https://your-instance.com/api/secrets
```
**Important:**
- API keys are shown only once upon creation - store them securely
- Maximum 5 API keys per user
- Keys can optionally expire after 30, 90, or 365 days
- Revoke compromised keys immediately from your account settings
For endpoints requiring admin access, the authenticated user must have the `admin` role.
## Quick Reference
### Public Endpoints
| Method | Endpoint | Description |
| ------ | ------------------------------- | ------------------------------------------------- |
| GET | `/api/healthz` | Health check |
| POST | `/api/secrets` | Create a new secret |
| POST | `/api/secrets/:id` | Retrieve a secret (password in body if protected) |
| GET | `/api/secrets/:id/check` | Check if secret exists and requires password |
| POST | `/api/files` | Upload a file |
| GET | `/api/files/:id` | Download a file |
| GET | `/api/instance/settings/public` | Get public instance settings |
| GET | `/api/setup/status` | Check if initial setup is needed |
| POST | `/api/setup/complete` | Complete initial setup |
### Authenticated Endpoints
| Method | Endpoint | Description |
| ------ | ----------------------- | ------------------- |
| GET | `/api/secrets` | List user's secrets |
| DELETE | `/api/secrets/:id` | Delete a secret |
| GET | `/api/account` | Get account info |
| PUT | `/api/account` | Update account info |
| PUT | `/api/account/password` | Update password |
| DELETE | `/api/account` | Delete account |
| GET | `/api/api-keys` | List API keys |
| POST | `/api/api-keys` | Create API key |
| DELETE | `/api/api-keys/:id` | Delete API key |
### Admin Endpoints
| Method | Endpoint | Description |
| ------ | ------------------------------- | ------------------------- |
| GET | `/api/instance/settings` | Get all instance settings |
| PUT | `/api/instance/settings` | Update instance settings |
| GET | `/api/analytics` | Get secret analytics |
| GET | `/api/analytics/visitors/daily` | Get daily visitor stats |
| GET | `/api/invites` | List invite codes |
| POST | `/api/invites` | Create invite code |
| DELETE | `/api/invites/:id` | Deactivate invite code |
| PUT | `/api/user/:id` | Update user |
## Example: Create a Secret
```bash
curl -X POST https://your-instance.com/api/secrets \
-H "Content-Type: application/json" \
-d '{
"secret": "BASE64_ENCRYPTED_CONTENT",
"salt": "ENCRYPTION_SALT",
"expiresAt": 3600,
"views": 1
}'
```
Response:
```json
{
"id": "abc123xyz"
}
```
## Important Notes
- **Client-side encryption:** All secret content should be encrypted client-side before sending to the API. The server only stores encrypted data.
- **Decryption keys:** Never send decryption keys to the server. They should be passed via URL fragments (`#key=...`) which are not transmitted to the server.
- **Rate limiting:** API requests may be rate-limited based on instance settings.

374
docs/cli.md Normal file
View File

@@ -0,0 +1,374 @@
# Hemmelig CLI
The Hemmelig CLI allows you to create encrypted secrets directly from the command line, making it ideal for automation, CI/CD pipelines, and scripting.
```
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|___/
```
## Installation
### Binary (Recommended for CI/CD)
Download the pre-built binary for your platform from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true).
Replace `VERSION` below with the desired version (e.g., `1.0.0`):
#### Linux (amd64)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-amd64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### Linux (arm64)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-arm64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### macOS (Apple Silicon)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-arm64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### macOS (Intel)
```bash
VERSION=1.0.1
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-amd64 -o hemmelig
chmod +x hemmelig
sudo mv hemmelig /usr/local/bin/
```
#### Windows
Download `hemmelig-windows-amd64.exe` from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true) and add it to your PATH.
#### Verify Download
```bash
VERSION=1.0.1
# Download checksums
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/checksums.txt -o checksums.txt
# Verify integrity
sha256sum -c checksums.txt --ignore-missing
```
### npm
```bash
# Install globally
npm install -g hemmelig
# Or use with npx (no installation required)
npx hemmelig "my secret"
```
## Usage
```bash
hemmelig <secret> [options]
```
Or pipe content from stdin:
```bash
echo "my secret" | hemmelig [options]
cat file.txt | hemmelig [options]
```
## Options
| Option | Description |
| ----------------------- | --------------------------------------------------- |
| `-t, --title <title>` | Set a title for the secret |
| `-p, --password <pass>` | Protect with a password (if not set, key is in URL) |
| `-e, --expires <time>` | Expiration time (default: 1d) |
| `-v, --views <number>` | Max views before deletion (default: 1, max: 9999) |
| `-b, --burnable` | Burn after first view (default: true) |
| `--no-burnable` | Don't burn after first view |
| `-u, --url <url>` | Base URL (default: https://hemmelig.app) |
| `-h, --help, /?` | Show help message |
### Expiration Times
Valid expiration values: `5m`, `30m`, `1h`, `4h`, `12h`, `1d`, `3d`, `7d`, `14d`, `28d`
## Examples
### Basic Usage
```bash
# Create a simple secret (expires in 1 day, 1 view)
hemmelig "my secret message"
# Create a secret with a title
hemmelig "database_password=secret123" -t "Database Credentials"
# Set custom expiration and view count
hemmelig "temporary token" -e 1h -v 3
```
### Password Protection
```bash
# Create a password-protected secret
hemmelig "sensitive data" -p "mypassword123"
```
When password-protected, the recipient must enter the password to decrypt the secret. The URL will not contain the decryption key.
### Self-Hosted Instances
```bash
# Use your own Hemmelig instance
hemmelig "internal secret" -u https://secrets.company.com
```
## CI/CD Integration
The CLI is designed for automation. It outputs only the secret URL to stdout, making it easy to capture and use in scripts.
### GitHub Actions
Share secrets securely between workflow jobs or with external parties:
```yaml
name: Deploy
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Share deployment credentials
run: |
SECRET_URL=$(npx hemmelig "${{ secrets.DEPLOY_KEY }}" \
-t "Deployment Key" \
-e 1h \
-v 1 \
-u https://secrets.company.com)
echo "Secure link: $SECRET_URL"
# Send to Slack, email, etc.
```
### GitLab CI
```yaml
share-credentials:
stage: deploy
script:
- |
SECRET_URL=$(npx hemmelig "$DB_PASSWORD" \
-t "Database Password" \
-e 4h \
-u https://secrets.company.com)
echo "Secret URL: $SECRET_URL"
```
### Jenkins Pipeline
```groovy
pipeline {
agent any
stages {
stage('Share Secret') {
steps {
script {
def secretUrl = sh(
script: '''
npx hemmelig "${API_KEY}" \
-t "API Key for deployment" \
-e 1h \
-u https://secrets.company.com
''',
returnStdout: true
).trim()
echo "Secret available at: ${secretUrl}"
}
}
}
}
}
```
## Automation Use Cases
### Secure Credential Handoff
When onboarding new team members or sharing credentials with contractors:
```bash
#!/bin/bash
# generate-access.sh
DB_CREDS="host: db.internal.com
user: app_user
password: $(openssl rand -base64 32)"
SECRET_URL=$(echo "$DB_CREDS" | hemmelig \
-t "Database Access - $(date +%Y-%m-%d)" \
-e 24h \
-v 1)
echo "Send this link to the new team member: $SECRET_URL"
```
### Automated Secret Rotation
Share rotated secrets with dependent services:
```bash
#!/bin/bash
# rotate-and-share.sh
NEW_PASSWORD=$(openssl rand -base64 24)
# Update the password in your system
update_service_password "$NEW_PASSWORD"
# Share with the dependent team
SECRET_URL=$(hemmelig "$NEW_PASSWORD" \
-t "Rotated Service Password" \
-e 1h \
-v 1 \
-u https://secrets.company.com)
# Notify via Slack
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"Password rotated. New credentials: $SECRET_URL\"}"
```
### Sharing Build Artifacts Securely
```bash
#!/bin/bash
# share-artifact.sh
# Generate a signed URL or token for the artifact
ARTIFACT_TOKEN=$(generate_artifact_token)
SECRET_URL=$(hemmelig "$ARTIFACT_TOKEN" \
-t "Build Artifact Access Token" \
-e 4h \
-v 5)
echo "Artifact access link: $SECRET_URL"
```
### Emergency Access Credentials
Create break-glass credentials that self-destruct:
```bash
#!/bin/bash
# emergency-access.sh
EMERGENCY_CREDS=$(cat << EOF
Emergency Admin Access
======================
URL: https://admin.company.com
Username: emergency_admin
Password: $(openssl rand -base64 32)
MFA Backup: $(generate_mfa_backup)
This access expires in 1 hour.
EOF
)
SECRET_URL=$(echo "$EMERGENCY_CREDS" | hemmelig \
-t "Emergency Access Credentials" \
-e 1h \
-v 1 \
-p "emergency-$(date +%s)")
echo "Emergency access: $SECRET_URL"
echo "Password hint: emergency-[unix timestamp]"
```
## Programmatic Usage
The CLI can also be used as a library in your Node.js projects:
```typescript
import { createSecret } from 'hemmelig';
const result = await createSecret({
secret: 'my secret message',
title: 'API Key',
expiresIn: '1h',
views: 1,
burnable: true,
baseUrl: 'https://hemmelig.app', // optional
});
console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=...
console.log(result.id); // abc123
```
### API Reference
#### `createSecret(options: SecretOptions): Promise<CreateSecretResult>`
| Option | Type | Default | Description |
| ----------- | --------------- | ------------------------ | ----------------------------- |
| `secret` | `string` | required | The secret content to encrypt |
| `title` | `string` | - | Optional title |
| `password` | `string` | - | Password protection |
| `expiresIn` | `ExpirationKey` | `'1d'` | Expiration time |
| `views` | `number` | `1` | Max views (1-9999) |
| `burnable` | `boolean` | `true` | Burn on first view |
| `baseUrl` | `string` | `'https://hemmelig.app'` | Server URL |
**Returns:**
| Property | Type | Description |
| ----------- | -------- | ----------------------------- |
| `url` | `string` | Full URL to access the secret |
| `id` | `string` | The secret ID |
| `expiresIn` | `string` | The expiration time set |
## Security Notes
- **Client-side encryption**: All encryption happens locally before data is sent to the server
- **Zero-knowledge**: The server never sees your plaintext secrets or encryption keys
- **URL fragments**: When not using a password, the decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server
- **Self-destructing**: Secrets are automatically deleted after the specified views or expiration time
## Troubleshooting
### Secret Creation Fails
If you're using a self-hosted instance and secret creation fails, ensure:
1. The instance URL is correct and accessible
2. The server is running and healthy
3. CORS is configured to allow requests from the CLI origin
### Piped Content Issues
When piping content, the CLI preserves all internal newlines and formatting. Only trailing whitespace is trimmed.
```bash
# This preserves the JSON formatting
cat config.json | hemmelig -t "Config"
```

267
docs/docker.md Normal file
View File

@@ -0,0 +1,267 @@
# Docker Deployment
Complete guide for deploying Hemmelig using Docker.
## Architecture Support
Hemmelig Docker images are built for multiple architectures:
| Architecture | Supported | Use Case |
| ------------- | --------- | -------------------------------------------- |
| `linux/amd64` | Yes | Intel/AMD servers, most cloud providers |
| `linux/arm64` | Yes | Apple Silicon, AWS Graviton, Raspberry Pi 4+ |
Docker will automatically pull the correct image for your platform.
## Quick Start
```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="your-secret-key-min-32-chars" \
-e BETTER_AUTH_URL="https://your-domain.com" \
hemmeligapp/hemmelig:v7
```
## Docker Compose
The repository includes a ready-to-use `docker-compose.yml`:
```bash
# Clone the repository
git clone https://github.com/HemmeligOrg/Hemmelig.app.git
cd Hemmelig.app
# Edit environment variables
nano docker-compose.yml
# Start the application
docker compose up -d
```
### Configuration
The included `docker-compose.yml` uses SQLite:
```yaml
services:
hemmelig:
image: hemmeligapp/hemmelig:v7
container_name: hemmelig
restart: unless-stopped
volumes:
- ./database:/app/database
- ./uploads:/app/uploads
environment:
- DATABASE_URL=file:/app/database/hemmelig.db
- BETTER_AUTH_SECRET=change-this-to-a-secure-secret-min-32-chars
- BETTER_AUTH_URL=https://secrets.example.com
- NODE_ENV=production
- HEMMELIG_BASE_URL=https://secrets.example.com
ports:
- '3000:3000'
healthcheck:
test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:3000/api/health/ready',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
```
**Important:** Before starting, update the following:
- `BETTER_AUTH_SECRET` - Generate with `openssl rand -base64 32`
- `HEMMELIG_BASE_URL` - Your public domain URL
## Volume Mounts
| Container Path | Purpose | Required |
| --------------- | ----------------------- | -------- |
| `/app/database` | SQLite database storage | Yes |
| `/app/uploads` | File upload storage | Yes |
## Environment Variables
See [Environment Variables](./env.md) for a complete reference.
### Required Variables
| Variable | Description |
| -------------------- | -------------------------------------------------------- |
| `DATABASE_URL` | Database connection string |
| `BETTER_AUTH_SECRET` | Authentication secret (min 32 characters) |
| `BETTER_AUTH_URL` | Public URL of your instance (for proper cookie handling) |
### Common Variables
| Variable | Description | Default |
| ------------------- | ---------------------------------------------- | ------------- |
| `NODE_ENV` | Set to `production` for production deployments | `development` |
| `HEMMELIG_BASE_URL` | Public URL of your instance | - |
| `HEMMELIG_PORT` | Internal port (usually leave as default) | `3000` |
## Troubleshooting
### Database Permission Errors
If you see errors like:
```
Error: Migration engine error:
SQLite database error
unable to open database file: /app/database/hemmelig.db
```
This means the container cannot write to the mounted volume. Fix by setting correct ownership on the host:
```bash
# Find your user ID
id -u
# Create directories and set ownership
sudo mkdir -p ./database ./uploads
sudo chown -R $(id -u):$(id -g) ./database ./uploads
```
Or use Docker named volumes instead of bind mounts:
```yaml
volumes:
- hemmelig-data:/app/database
- hemmelig-uploads:/app/uploads
```
### File Upload Permission Errors
If file uploads fail, ensure the uploads directory has correct permissions:
```bash
sudo chown -R $(id -u):$(id -g) ./uploads
chmod 755 ./uploads
```
### Container User
The Hemmelig container runs as user `bun` (non-root) for security. When using bind mounts, ensure the host directories are writable by UID 1000 (the default `bun` user in the container).
## Building from Source
To build the Docker image locally:
```bash
git clone https://github.com/HemmeligOrg/Hemmelig.app.git
cd Hemmelig.app
docker build -t hemmelig .
```
### Building for ARM64
To build for ARM64 (e.g., for Apple Silicon or AWS Graviton):
```bash
# Set up Docker buildx with multi-architecture support
docker buildx create --name multiarch --driver docker-container --use
# Build for ARM64
docker buildx build --platform linux/arm64 -t hemmelig:arm64 --load .
# Build for both architectures
docker buildx build --platform linux/amd64,linux/arm64 -t hemmelig:latest --push .
```
The Dockerfile uses a cross-compilation strategy where Prisma client generation runs on the build host's native architecture to avoid QEMU emulation issues.
## Reverse Proxy
### Nginx
1. Create the Nginx configuration file:
```bash
sudo nano /etc/nginx/sites-available/hemmelig
```
2. Add the following configuration (HTTP only, for initial setup):
```nginx
server {
listen 80;
server_name your_domain.com; # Replace with your domain or IP
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
3. Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/hemmelig /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
4. Install Certbot and obtain SSL certificate:
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain.com
```
Certbot will automatically modify your Nginx configuration to use HTTPS.
````
## Health Checks
The container exposes a health endpoint at `/api/health/ready`. The built-in healthcheck uses `wget` to verify the application is responding and all dependencies (database, storage) are healthy.
To manually check:
```bash
curl http://localhost:3000/api/health/ready
# Returns: JSON with status and component health details
````
## Updating
```bash
# Pull latest image
docker pull hemmeligapp/hemmelig:v7
# Recreate container
docker compose down
docker compose up -d
```
Database migrations run automatically on startup.
## Security Notes
1. **Always use HTTPS** in production with a reverse proxy
2. **Generate secure secrets**: `openssl rand -base64 32`
3. **Keep the image updated** for security patches
4. **Back up your data** regularly, especially the database

178
docs/e2e.md Normal file
View File

@@ -0,0 +1,178 @@
# End-to-End Testing with Playwright
Hemmelig uses [Playwright](https://playwright.dev/) for end-to-end integration testing.
## Setup
Playwright and browsers are installed as dev dependencies. If you need to install browser dependencies on your system:
```bash
sudo npx playwright install-deps
```
### Test Database
The e2e tests automatically use a **separate test database** (`database/hemmelig-test.db`) that is:
1. Created fresh before each test run
2. Migrated with the latest schema
3. Seeded with a test user
4. Deleted after tests complete
This ensures tests don't affect your development database.
**Test User Credentials** (created automatically):
- Email: `e2e-test@hemmelig.local`
- Username: `e2etestuser`
- Password: `E2ETestPassword123!`
## Running Tests
```bash
# Run all e2e tests
npm run test:e2e
# Run tests with interactive UI
npm run test:e2e:ui
# Run tests in debug mode
npm run test:e2e:debug
# Run a specific test file
npx playwright test tests/e2e/secret.spec.ts
# Run tests in headed mode (see the browser)
npx playwright test --headed
```
## Test Structure
Tests are located in `tests/e2e/`:
| File | Description |
| -------------------- | ------------------------------------------------- |
| `auth.spec.ts` | Authentication tests (setup, login, registration) |
| `home.spec.ts` | Homepage and secret form tests |
| `secret.spec.ts` | Secret creation, viewing, and deletion flows |
| `navigation.spec.ts` | Navigation and routing tests |
| `fixtures.ts` | Shared test fixtures (`authenticatedPage`) |
| `global-setup.ts` | Creates test database and user before tests |
| `global-teardown.ts` | Cleans up test database after tests |
## Configuration
The Playwright configuration is in `playwright.config.ts`:
- **Test directory**: `tests/e2e/`
- **Base URL**: `http://localhost:5173`
- **Browser**: Chromium (Desktop Chrome)
- **Web server**: Automatically starts Vite with test database
- **Global setup**: Creates fresh test database and test user
- **Global teardown**: Deletes test database
## Writing Tests
### Basic Test Structure
```typescript
import { expect, test } from '@playwright/test';
test.describe('Feature Name', () => {
test('should do something', async ({ page }) => {
await page.goto('/');
// Interact with elements
await page.locator('.ProseMirror').fill('My secret');
await page.getByRole('button', { name: /create/i }).click();
// Assert results
await expect(page.getByText(/success/i)).toBeVisible();
});
});
```
### Using Authenticated Page Fixture
For tests that require authentication:
```typescript
import { expect, test } from './fixtures';
test('should create a secret when logged in', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/');
// authenticatedPage is already logged in with test user
await expect(authenticatedPage.locator('.ProseMirror')).toBeVisible();
});
```
### Common Patterns
**Interacting with the secret editor:**
```typescript
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Secret content');
```
**Creating and viewing a secret:**
```typescript
// Create
await page.goto('/');
await page.locator('.ProseMirror').fill('My secret');
await page
.getByRole('button', { name: /create/i })
.first()
.click();
// Get the URL
const urlInput = page.locator('input[readonly]').first();
const secretUrl = await urlInput.inputValue();
// View
await page.goto(secretUrl);
await page.getByRole('button', { name: /unlock/i }).click();
```
## CI Integration
Tests run in CI with these settings (from `playwright.config.ts`):
- `forbidOnly: true` - Fails if `.only` is left in tests
- `retries: 2` - Retries failed tests twice
- `workers: 1` - Single worker to prevent conflicts
### GitHub Actions Example
```yaml
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run E2E Tests
run: npm run test:e2e
```
## Viewing Test Reports
After running tests, view the HTML report:
```bash
npx playwright show-report
```
## Debugging Failed Tests
1. **Run in debug mode**: `npm run test:e2e:debug`
2. **Run with UI**: `npm run test:e2e:ui`
3. **View traces**: Failed tests generate traces in `test-results/`
4. **Screenshots**: Failed tests save screenshots automatically
## Best Practices
1. **Use data-testid for stable selectors** when possible
2. **Prefer user-facing selectors** like `getByRole`, `getByText`, `getByPlaceholder`
3. **Add appropriate timeouts** for async operations
4. **Keep tests independent** - each test should work in isolation
5. **Use `.first()` when multiple elements match** to avoid strict mode violations

123
docs/encryption.md Normal file
View File

@@ -0,0 +1,123 @@
# Encryption
Hemmelig uses a **zero-knowledge architecture** where all encryption and decryption happens entirely in your browser. The server never sees your plaintext secrets or encryption keys.
## How It Works
1. **Secret Creation**: When you create a secret, it's encrypted in your browser before being sent to the server
2. **Key Transmission**: The decryption key is passed via URL fragment (`#decryptionKey=...`), which is never sent to the server
3. **Secret Retrieval**: When viewing a secret, the encrypted data is fetched and decrypted locally in your browser
## Why URL Fragments?
The decryption key is placed in the URL fragment (the part after `#`) for a critical security reason:
**URL fragments are never transmitted to servers.**
When you visit a URL like `https://example.com/secret/abc123#decryptionKey=xyz`:
- The browser sends a request to `https://example.com/secret/abc123`
- The fragment (`#decryptionKey=xyz`) stays in your browser
- Server logs, proxies, load balancers, and CDNs never see the fragment
- The key exists only in the browser's address bar and JavaScript
This is defined in [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3.5) and is a fundamental behavior of all web browsers.
### What This Means
| Component | Sees the Key? |
| ----------------------------- | ------------- |
| Your browser | ✅ Yes |
| Hemmelig server | ❌ No |
| Reverse proxies (nginx, etc.) | ❌ No |
| CDNs (Cloudflare, etc.) | ❌ No |
| Server access logs | ❌ No |
| Network monitoring tools | ❌ No |
**Note**: Be aware that browser history and bookmarks store the full URL including fragments.
## Technical Details
### Why This Encryption?
Hemmelig uses **AES-256-GCM** via the **Web Crypto API** for several important reasons:
- **Browser-native**: The Web Crypto API is built into all modern browsers. No external libraries required.
- **Hardware-accelerated**: AES is supported by dedicated instructions (AES-NI) in most modern CPUs (Intel, AMD, ARM), making encryption and decryption fast.
- **Battle-tested**: AES-256 is a NIST-approved standard.
- **Authenticated encryption**: GCM mode provides both confidentiality and integrity, detecting any tampering with the ciphertext.
- **No dependencies**: By using native browser APIs, we avoid supply chain risks from third-party cryptography libraries.
### Algorithm
- **Encryption**: AES-256-GCM (Galois/Counter Mode)
- **Key Derivation**: PBKDF2 with SHA-256
- **Implementation**: Web Crypto API (browser-native)
### Parameters
| Parameter | Value | Description |
| ----------------- | ------------------ | -------------------------------------------------------- |
| Algorithm | AES-GCM | Authenticated encryption with associated data |
| Key Length | 256 bits | Maximum AES key size |
| IV Length | 96 bits (12 bytes) | Initialization vector, randomly generated per encryption |
| Salt Length | 32 characters | Unique per secret, stored server-side |
| PBKDF2 Iterations | 1,300,000 | Key derivation iterations |
| PBKDF2 Hash | SHA-256 | Hash function for key derivation |
### Encryption Process
1. **Key Generation**: A 32-character random key is generated using `nanoid`, or a user-provided password is used directly
2. **Key Derivation**: PBKDF2 derives a 256-bit AES key from the password/key and a unique salt
3. **Encryption**: AES-256-GCM encrypts the plaintext with a random 96-bit IV
4. **Output Format**: `IV (12 bytes) || Ciphertext`
## Password Protection
When you set a password on a secret:
- The password is used directly as the encryption key instead of a randomly generated key
- The URL does **not** include the `#decryptionKey=...` fragment
- The recipient must enter the password manually to decrypt the secret
- This allows you to share the URL and password through separate channels for additional security
### Decryption Process
1. **Parse**: Extract the 12-byte IV from the beginning of the encrypted data
2. **Key Derivation**: PBKDF2 derives the same AES key using the password/key and salt
3. **Decryption**: AES-GCM decrypts and authenticates the ciphertext
## Security Properties
- **Confidentiality**: AES-256 provides strong encryption
- **Integrity**: GCM mode provides authenticated encryption, detecting any tampering
- **Key Strength**: PBKDF2 with 1,300,000 iterations provides resistance against brute-force attacks
- **Forward Secrecy**: Each secret uses a unique salt and random IV
## File Encryption
Files are encrypted using the same AES-256-GCM scheme. The file buffer is encrypted directly, and the output format is identical: `IV || Ciphertext`.
## What the Server Stores
- Encrypted secret (ciphertext)
- Salt (used for key derivation)
- Metadata (expiration, view count, etc.)
## What the Server Never Sees
- Plaintext secrets
- Encryption keys or passwords
- Decryption keys (passed via URL fragment)
## References
- [MDN Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) - Browser-native cryptography documentation
- [MDN AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams) - AES-GCM algorithm parameters
- [MDN PBKDF2](https://developer.mozilla.org/en-US/docs/Web/API/Pbkdf2Params) - PBKDF2 key derivation parameters
- [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html) - Best practices for cryptographic storage
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Key derivation recommendations
- [NIST SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) - Password-Based Key Derivation (current version)
- [NIST SP 800-132 Revision Proposal](https://csrc.nist.gov/News/2023/proposal-to-revise-nist-sp-800-132-pbkdf) - Upcoming revision with memory-hard functions
- [NIST AES Specification](https://csrc.nist.gov/publications/detail/fips/197/final) - Official AES standard (FIPS 197)
- [Crypto 101](https://www.crypto101.io/) - Free introductory course on cryptography

186
docs/env.md Normal file
View File

@@ -0,0 +1,186 @@
# Environment Variables
Complete reference for all environment variables supported by Hemmelig.
## Required Variables
| Variable | Description | Default |
| -------------------- | -------------------------------------------------------- | ------------------------- |
| `DATABASE_URL` | SQLite connection string | `file:./data/hemmelig.db` |
| `BETTER_AUTH_SECRET` | Secret key for authentication sessions | - |
| `BETTER_AUTH_URL` | Public URL of your instance (for proper cookie handling) | - |
## Server Configuration
| Variable | Description | Default |
| ------------------------- | ------------------------------------------------ | ------------- |
| `NODE_ENV` | Environment mode (`production` or `development`) | `development` |
| `HEMMELIG_PORT` | Port the server listens on | `3000` |
| `HEMMELIG_BASE_URL` | Public URL of your instance (required for OAuth) | - |
| `HEMMELIG_TRUSTED_ORIGIN` | Additional trusted origin for CORS | - |
## General Settings
| Variable | Description | Default |
| ------------------------------- | --------------------------------------------- | ------- |
| `HEMMELIG_INSTANCE_NAME` | Custom name for your instance | - |
| `HEMMELIG_INSTANCE_DESCRIPTION` | Custom description for your instance | - |
| `HEMMELIG_ALLOW_REGISTRATION` | Allow new user registrations (`true`/`false`) | `true` |
## Security Settings
| Variable | Description | Default |
| ---------------------------------------- | ------------------------------------------------------------- | ------- |
| `HEMMELIG_ALLOW_PASSWORD_PROTECTION` | Allow password-protected secrets | `true` |
| `HEMMELIG_ALLOW_IP_RESTRICTION` | Allow IP range restrictions on secrets | `true` |
| `HEMMELIG_ALLOW_FILE_UPLOADS` | Allow users to attach files to secrets | `true` |
| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password registration (social login only) | `false` |
| `HEMMELIG_MAX_ENCRYPTED_PAYLOAD_SIZE` | Hard ceiling for encrypted payloads in KB (parsed at startup) | `1024` |
## Analytics
| Variable | Description | Default |
| -------------------------------- | --------------------------------------- | -------------- |
| `HEMMELIG_ANALYTICS_ENABLED` | Enable privacy-focused analytics | `true` |
| `HEMMELIG_ANALYTICS_HMAC_SECRET` | HMAC secret for anonymizing visitor IDs | auto-generated |
## Social Login Providers
See [Social Login Documentation](./social-login.md) for detailed setup instructions.
### GitHub
| Variable | Description |
| ----------------------------- | ------------------------------ |
| `HEMMELIG_AUTH_GITHUB_ID` | GitHub OAuth App Client ID |
| `HEMMELIG_AUTH_GITHUB_SECRET` | GitHub OAuth App Client Secret |
### Google
| Variable | Description |
| ----------------------------- | -------------------------- |
| `HEMMELIG_AUTH_GOOGLE_ID` | Google OAuth Client ID |
| `HEMMELIG_AUTH_GOOGLE_SECRET` | Google OAuth Client Secret |
### Microsoft (Azure AD)
| Variable | Description |
| ----------------------------------- | --------------------------------------------------- |
| `HEMMELIG_AUTH_MICROSOFT_ID` | Microsoft Application (client) ID |
| `HEMMELIG_AUTH_MICROSOFT_SECRET` | Microsoft Client Secret |
| `HEMMELIG_AUTH_MICROSOFT_TENANT_ID` | Azure AD Tenant ID (optional, defaults to "common") |
### Discord
| Variable | Description |
| ------------------------------ | --------------------------------- |
| `HEMMELIG_AUTH_DISCORD_ID` | Discord Application Client ID |
| `HEMMELIG_AUTH_DISCORD_SECRET` | Discord Application Client Secret |
### GitLab
| Variable | Description |
| ----------------------------- | ------------------------- |
| `HEMMELIG_AUTH_GITLAB_ID` | GitLab Application ID |
| `HEMMELIG_AUTH_GITLAB_SECRET` | GitLab Application Secret |
### Apple
| Variable | Description |
| ---------------------------- | ------------------- |
| `HEMMELIG_AUTH_APPLE_ID` | Apple Services ID |
| `HEMMELIG_AUTH_APPLE_SECRET` | Apple Client Secret |
### Twitter/X
| Variable | Description |
| ------------------------------ | ------------------------------- |
| `HEMMELIG_AUTH_TWITTER_ID` | Twitter OAuth 2.0 Client ID |
| `HEMMELIG_AUTH_TWITTER_SECRET` | Twitter OAuth 2.0 Client Secret |
### Generic OAuth
Hemmelig supports any OAuth 2.0 / OpenID Connect provider through generic OAuth configuration.
| Variable | Description |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `HEMMELIG_AUTH_GENERIC_OAUTH` | JSON array of generic OAuth provider configurations. See [Social Login docs](./social-login.md) for details. |
**Example**:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH='[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]'
```
Supported generic providers include: Authentik, Authelia, Keycloak, Zitadel, Ory Hydra, and any OAuth 2.0 / OIDC-compatible identity provider.
## Example Configuration
### Minimal Setup
```bash
# Required
DATABASE_URL=file:./data/hemmelig.db
BETTER_AUTH_SECRET=your-secret-key-min-32-chars-long
BETTER_AUTH_URL=https://secrets.example.com
```
### Production Setup
```bash
# Required
DATABASE_URL=file:./data/hemmelig.db
BETTER_AUTH_SECRET=your-very-secure-secret-key-here
BETTER_AUTH_URL=https://secrets.example.com
# Server
NODE_ENV=production
HEMMELIG_PORT=3000
HEMMELIG_TRUSTED_ORIGIN=https://secrets.example.com
# Instance
HEMMELIG_INSTANCE_NAME=Company Secrets
HEMMELIG_INSTANCE_DESCRIPTION=Secure secret sharing for our team
# Security
HEMMELIG_ENABLE_RATE_LIMITING=true
# Analytics
HEMMELIG_ANALYTICS_ENABLED=true
HEMMELIG_ANALYTICS_HMAC_SECRET=your-analytics-hmac-secret
# Social Login (optional)
HEMMELIG_AUTH_GITHUB_ID=your-github-client-id
HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret
```
### Docker Compose Example
```yaml
version: '3.8'
services:
hemmelig:
image: hemmelig/hemmelig:latest
ports:
- '3000:3000'
environment:
- DATABASE_URL=file:/data/hemmelig.db
- BETTER_AUTH_SECRET=change-this-to-a-secure-secret
- BETTER_AUTH_URL=https://secrets.example.com
- NODE_ENV=production
- HEMMELIG_PORT=3000
- HEMMELIG_ANALYTICS_ENABLED=true
volumes:
- hemmelig_data:/data
volumes:
hemmelig_data:
```
## Notes
- Boolean values accept `true` or `false` (case-insensitive)
- All `HEMMELIG_AUTH_*` variables require both `_ID` and `_SECRET` to enable a provider
- `BETTER_AUTH_URL` is required when using social login providers
- Generate secure secrets using: `openssl rand -base64 32`

95
docs/health.md Normal file
View File

@@ -0,0 +1,95 @@
# Health Check Endpoints
Hemmelig provides health check endpoints for monitoring and container orchestration.
## Endpoints
### Liveness Probe
```
GET /api/health/live
```
Simple check confirming the process is running. Use for Kubernetes liveness probes.
**Response:** `200 OK`
```json
{
"status": "healthy",
"timestamp": "2024-01-15T10:30:00.000Z"
}
```
### Readiness Probe
```
GET /api/health/ready
```
Comprehensive check verifying all dependencies are operational. Use for Kubernetes readiness probes.
**Checks performed:**
| Check | Description |
| ------------ | ---------------------------------------- |
| **Database** | Executes `SELECT 1`, measures latency |
| **Storage** | Verifies uploads directory is read/write |
| **Memory** | Checks RSS is below 1GB threshold |
**Response:** `200 OK` (all healthy) or `503 Service Unavailable` (one or more failed)
```json
{
"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
}
}
}
```
### Legacy Endpoint
```
GET /api/healthz
```
Kept for backwards compatibility. Consider using `/api/health/live` instead.
## Kubernetes Configuration
```yaml
livenessProbe:
httpGet:
path: /api/health/live
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health/ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
```
## Docker Compose
```yaml
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health/ready']
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
```

141
docs/helm-oauth.md Normal file
View File

@@ -0,0 +1,141 @@
# Hemmelig Helm Chart - OAuth Configuration Examples
This document demonstrates how to configure OAuth providers with the Hemmelig Helm Chart.
## Using Default Secret Management
The chart can automatically create secrets with your OAuth configuration.
The example below contains all providers supported by the Helm Chart:
```yaml
# values.yaml
config:
betterAuthSecret: "your-auth-secret-here"
betterAuthUrl: "https://secrets.example.com"
baseUrl: "https://secrets.example.com" # Required for OAuth callbacks
oauth:
github:
enabled: true
clientId: "your-github-client-id"
clientSecret: "your-github-client-secret"
google:
enabled: true
clientId: "your-google-client-id"
clientSecret: "your-google-client-secret"
microsoft:
enabled: true
clientId: "your-microsoft-client-id"
clientSecret: "your-microsoft-client-secret"
tenantId: "your-tenant-id" # Optional
discord:
enabled: true
clientId: "your-discord-client-id"
clientSecret: "your-discord-client-secret"
gitlab:
enabled: true
clientId: "your-gitlab-client-id"
clientSecret: "your-gitlab-client-secret"
issuer: "https://gitlab.example.com" # Optional, for self-hosted GitLab
apple:
enabled: true
clientId: "your-apple-client-id"
clientSecret: "your-apple-client-secret"
twitter:
enabled: true
clientId: "your-twitter-client-id"
clientSecret: "your-twitter-client-secret"
generic: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]'
```
## Using Existing Secret
If you prefer to manage secrets yourself, reference an existing secret
and enable your desired providers:
```yaml
# values.yaml
existingSecret: "hemmelig-secrets"
oauth:
github:
enabled: true
google:
enabled: true
microsoft:
enabled: true
discord:
enabled: true
gitlab:
enabled: true
apple:
enabled: true
twitter:
enabled: true
generic: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]'
```
Your referenced secret should contain the relevant keys for the providers enabled:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: hemmelig-secrets
type: Opaque
stringData:
BETTER_AUTH_SECRET: "your-auth-secret"
# GitHub
HEMMELIG_AUTH_GITHUB_ID: "github-client-id"
HEMMELIG_AUTH_GITHUB_SECRET: "github-client-secret"
# Google
HEMMELIG_AUTH_GOOGLE_ID: "google-client-id"
HEMMELIG_AUTH_GOOGLE_SECRET: "google-client-secret"
# Microsoft (Azure AD)
HEMMELIG_AUTH_MICROSOFT_ID: "microsoft-client-id"
HEMMELIG_AUTH_MICROSOFT_SECRET: "microsoft-client-secret"
HEMMELIG_AUTH_MICROSOFT_TENANT_ID: "tenant-id" # Optional
# Discord
HEMMELIG_AUTH_DISCORD_ID: "discord-client-id"
HEMMELIG_AUTH_DISCORD_SECRET: "discord-client-secret"
# GitLab
HEMMELIG_AUTH_GITLAB_ID: "gitlab-client-id"
HEMMELIG_AUTH_GITLAB_SECRET: "gitlab-client-secret"
HEMMELIG_AUTH_GITLAB_ISSUER: "https://gitlab.example.com" # Optional
# Apple
HEMMELIG_AUTH_APPLE_ID: "apple-client-id"
HEMMELIG_AUTH_APPLE_SECRET: "apple-client-secret"
# Twitter/X
HEMMELIG_AUTH_TWITTER_ID: "twitter-client-id"
HEMMELIG_AUTH_TWITTER_SECRET: "twitter-client-secret"
# Generic OAuth (JSON array - supports any OAuth 2.0 / OIDC provider)
HEMMELIG_AUTH_GENERIC_OAUTH: "[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"client-secret","scopes":["openid","profile","email"]}]"
```
## Notes
- All `HEMMELIG_AUTH_*` variables require both `_ID` and `_SECRET`
to enable a provider, except the "Generic" type.
If you enable a provider and not include the required environment variables for it,
the pod will fail to start with CreateContainerConfigError, with an event
similar to the one below:
```
Error: couldn't find key HEMMELIG_AUTH_<missing_env> in Secret default/hemmelig
```
- All OAuth environment variables will be automatically injected into
the deployment, sourced either from the chart-generated secret
or your existing secret.
- If the `existingSecret` value is provided, the `clientId`, `clientSecret`, etc.
values are ignored from the `values.yaml`

205
docs/helm.md Normal file
View File

@@ -0,0 +1,205 @@
# Helm Deployment
Deploy Hemmelig on Kubernetes using Helm.
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- PV provisioner support (for persistence)
## Quick Start
```bash
# Add the chart from local directory
cd Hemmelig.app
# Install with default values
helm install hemmelig ./helm/hemmelig \
--set config.betterAuthSecret="$(openssl rand -base64 32)" \
--set config.betterAuthUrl="https://hemmelig.example.com"
```
## Installation
### From Local Chart
```bash
# Clone the repository
git clone https://github.com/HemmeligOrg/Hemmelig.app.git
cd Hemmelig.app
# Install the chart
helm install hemmelig ./helm/hemmelig -f my-values.yaml
```
### Example values.yaml
```yaml
# my-values.yaml
config:
betterAuthSecret: 'your-secret-key-min-32-chars'
betterAuthUrl: 'https://hemmelig.example.com'
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: hemmelig.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: hemmelig-tls
hosts:
- hemmelig.example.com
persistence:
data:
enabled: true
size: 1Gi
uploads:
enabled: true
size: 10Gi
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
```
## Configuration
### Required Values
| Parameter | Description |
| ------------------------- | ---------------------------------------------------------------------------------- |
| `config.betterAuthSecret` | Authentication secret (min 32 characters). Generate with `openssl rand -base64 32` |
| `config.betterAuthUrl` | Public URL of your instance (required for OAuth and cookie handling) |
### Common Values
| Parameter | Description | Default |
| ----------------------------- | ------------------------------- | ------------------- |
| `replicaCount` | Number of replicas | `1` |
| `image.repository` | Image repository | `hemmelig/hemmelig` |
| `image.tag` | Image tag | `v7` |
| `service.type` | Kubernetes service type | `ClusterIP` |
| `service.port` | Service port | `3000` |
| `ingress.enabled` | Enable ingress | `false` |
| `persistence.data.enabled` | Enable persistence for database | `true` |
| `persistence.data.size` | Database PVC size | `1Gi` |
| `persistence.uploads.enabled` | Enable persistence for uploads | `true` |
| `persistence.uploads.size` | Uploads PVC size | `5Gi` |
### Using Existing Secrets
Instead of setting `config.betterAuthSecret` directly, use an existing Kubernetes secret:
```yaml
existingSecret: my-hemmelig-secret
```
Create the secret:
```bash
kubectl create secret generic my-hemmelig-secret \
--from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
```
### Additional Environment Variables
```yaml
env:
- name: HEMMELIG_ANALYTICS_ENABLED
value: 'true'
```
## OAuth Configuration
The Hemmelig Helm Chart supports comprehensive OAuth provider configuration. For detailed setup instructions and examples, see:
**[OAuth Configuration with Helm](helm-oauth.md)**
This guide covers:
- All supported OAuth providers (GitHub, Google, Microsoft, Discord, GitLab, Apple, Twitter/X)
- Generic OAuth providers (Authentik, Authelia, Keycloak, etc.)
- Default secret vs existing secret management
- Required configuration for OAuth callbacks
## Ingress Examples
### Nginx Ingress
```yaml
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: '50m'
hosts:
- host: hemmelig.example.com
paths:
- path: /
pathType: Prefix
```
### Traefik Ingress
```yaml
ingress:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.tls: 'true'
hosts:
- host: hemmelig.example.com
paths:
- path: /
pathType: Prefix
```
## Upgrading
```bash
helm upgrade hemmelig ./helm/hemmelig -f my-values.yaml
```
## Uninstalling
```bash
helm uninstall hemmelig
```
**Note:** PersistentVolumeClaims are not deleted automatically. To remove all data:
```bash
kubectl delete pvc -l app.kubernetes.io/name=hemmelig
```
## Troubleshooting
### Check Pod Status
```bash
kubectl get pods -l app.kubernetes.io/name=hemmelig
kubectl logs -l app.kubernetes.io/name=hemmelig
```
### Check PVC Status
```bash
kubectl get pvc -l app.kubernetes.io/name=hemmelig
```
### Port Forward for Testing
```bash
kubectl port-forward svc/hemmelig 3000:3000
# Visit http://localhost:3000
```

299
docs/managed.md Normal file
View File

@@ -0,0 +1,299 @@
# Managed Mode
Run Hemmelig with all instance settings controlled via environment variables. Perfect for containerized deployments, GitOps workflows, and infrastructure-as-code setups where you want configuration locked down and version-controlled.
## Overview
When managed mode is enabled, all instance settings are read from environment variables instead of the database. The admin dashboard becomes read-only, preventing any runtime modifications.
**Key benefits:**
- **Immutable configuration** - Settings can't be changed through the UI
- **GitOps-friendly** - Version control your configuration
- **Reproducible deployments** - Same config across all environments
- **Security hardening** - No accidental configuration changes
## Quick Start
Enable managed mode by setting:
```bash
HEMMELIG_MANAGED=true
```
Then configure your settings via environment variables:
```bash
# Enable managed mode
HEMMELIG_MANAGED=true
# Instance branding
HEMMELIG_INSTANCE_NAME="Company Secrets"
HEMMELIG_INSTANCE_DESCRIPTION="Secure secret sharing for our team"
# Security
HEMMELIG_ALLOW_PASSWORD_PROTECTION=true
HEMMELIG_ALLOW_IP_RESTRICTION=true
HEMMELIG_ENABLE_RATE_LIMITING=true
# Organization
HEMMELIG_REQUIRE_REGISTERED_USER=true
HEMMELIG_ALLOWED_EMAIL_DOMAINS="company.com,partner.com"
```
## Environment Variables
### Core
| Variable | Description | Default |
| ------------------ | ------------------- | ------- |
| `HEMMELIG_MANAGED` | Enable managed mode | `false` |
### General Settings
| Variable | Description | Default |
| ------------------------------------- | ------------------------------------- | ------- |
| `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 | `""` |
### Security Settings
| Variable | Description | Default |
| ------------------------------------ | -------------------------------- | ------- |
| `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` |
### Organization Settings
| Variable | Description | Default |
| ---------------------------------------- | ------------------------------------------------- | ------- |
| `HEMMELIG_REQUIRE_INVITE_CODE` | Require invite code for registration | `false` |
| `HEMMELIG_ALLOWED_EMAIL_DOMAINS` | Comma-separated list of allowed domains | `""` |
| `HEMMELIG_REQUIRE_REGISTERED_USER` | Only registered users can create and read secrets | `false` |
| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password registration (social only) | `false` |
### Webhook Settings
| Variable | Description | Default |
| -------------------------- | ---------------------------------- | ------- |
| `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` |
### Metrics Settings
| Variable | Description | Default |
| -------------------------- | ---------------------------------- | ------- |
| `HEMMELIG_METRICS_ENABLED` | Enable Prometheus metrics endpoint | `false` |
| `HEMMELIG_METRICS_SECRET` | Bearer token for `/api/metrics` | `""` |
## Docker Compose Example
```yaml
services:
hemmelig:
image: hemmeligapp/hemmelig:v7
restart: unless-stopped
ports:
- '3000:3000'
volumes:
- ./database:/app/database
- ./uploads:/app/uploads
environment:
# Required
- DATABASE_URL=file:/app/database/hemmelig.db
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BETTER_AUTH_URL=https://secrets.example.com
- NODE_ENV=production
# Enable managed mode
- HEMMELIG_MANAGED=true
# General
- HEMMELIG_INSTANCE_NAME=ACME Secrets
- HEMMELIG_INSTANCE_DESCRIPTION=Internal secret sharing
- HEMMELIG_ALLOW_REGISTRATION=true
- HEMMELIG_DEFAULT_SECRET_EXPIRATION=24
- HEMMELIG_MAX_SECRET_SIZE=2048
# Security
- HEMMELIG_ALLOW_PASSWORD_PROTECTION=true
- HEMMELIG_ALLOW_IP_RESTRICTION=false
- HEMMELIG_ENABLE_RATE_LIMITING=true
- HEMMELIG_RATE_LIMIT_REQUESTS=50
- HEMMELIG_RATE_LIMIT_WINDOW=60
# Organization
- HEMMELIG_REQUIRE_REGISTERED_USER=true
- HEMMELIG_ALLOWED_EMAIL_DOMAINS=acme.com
# Metrics
- HEMMELIG_METRICS_ENABLED=true
- HEMMELIG_METRICS_SECRET=prometheus-scrape-token
```
## Kubernetes Example
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: hemmelig-config
data:
HEMMELIG_MANAGED: 'true'
HEMMELIG_INSTANCE_NAME: 'ACME Secrets'
HEMMELIG_ALLOW_REGISTRATION: 'true'
HEMMELIG_REQUIRE_REGISTERED_USER: 'true'
HEMMELIG_ALLOWED_EMAIL_DOMAINS: 'acme.com'
HEMMELIG_ENABLE_RATE_LIMITING: 'true'
HEMMELIG_METRICS_ENABLED: 'true'
---
apiVersion: v1
kind: Secret
metadata:
name: hemmelig-secrets
type: Opaque
stringData:
BETTER_AUTH_SECRET: 'your-secret-key-min-32-chars'
HEMMELIG_WEBHOOK_SECRET: 'webhook-signing-secret'
HEMMELIG_METRICS_SECRET: 'prometheus-token'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hemmelig
spec:
replicas: 1
selector:
matchLabels:
app: hemmelig
template:
metadata:
labels:
app: hemmelig
spec:
containers:
- name: hemmelig
image: hemmeligapp/hemmelig:v7
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: hemmelig-config
- secretRef:
name: hemmelig-secrets
env:
- name: DATABASE_URL
value: 'file:/app/database/hemmelig.db'
- name: BETTER_AUTH_URL
value: 'https://secrets.example.com'
- name: NODE_ENV
value: 'production'
```
## Admin Dashboard Behavior
When managed mode is enabled:
1. **Settings are read-only** - All form inputs are disabled
2. **Save buttons are hidden** - No option to modify settings
3. **Banner is displayed** - Admins see a clear "Managed Mode" indicator
4. **API rejects updates** - `PUT /api/instance/settings` returns `403 Forbidden`
Admins can still **view** all settings, making it easy to verify the configuration.
## API Behavior
### Check Managed Mode Status
```bash
curl https://secrets.example.com/api/instance/managed
```
Response:
```json
{
"managed": true
}
```
### Settings Update (Blocked)
When managed mode is enabled, attempting to update settings returns:
```bash
curl -X PUT https://secrets.example.com/api/instance/settings \
-H "Content-Type: application/json" \
-d '{"instanceName": "New Name"}'
```
Response:
```json
{
"error": "Instance is in managed mode. Settings cannot be modified."
}
```
## Migration Guide
### From Database Settings to Managed Mode
1. **Export current settings** - Note your current configuration from the admin dashboard
2. **Create environment configuration** - Translate settings to environment variables
3. **Enable managed mode** - Set `HEMMELIG_MANAGED=true`
4. **Deploy** - Restart with new configuration
Your database settings will be ignored once managed mode is active. The database is still used for secrets, users, and other data - only instance settings come from environment variables.
### Reverting to Database Settings
Simply remove or set `HEMMELIG_MANAGED=false`. Settings will be read from the database again, and the admin dashboard becomes editable.
## Best Practices
1. **Use secrets management** - Store sensitive values like `HEMMELIG_WEBHOOK_SECRET` and `HEMMELIG_METRICS_SECRET` in a secrets manager (Vault, AWS Secrets Manager, etc.)
2. **Version control your config** - Keep your docker-compose or Kubernetes manifests in git
3. **Use CI/CD for changes** - Deploy configuration changes through your pipeline, not manual edits
4. **Document your settings** - Add comments in your configuration files explaining each setting
5. **Test in staging first** - Validate configuration changes in a non-production environment
## Troubleshooting
### Settings Not Applying
- Verify `HEMMELIG_MANAGED=true` is set (case-insensitive)
- Check environment variables are being passed to the container
- Restart the application after changing environment variables
### Dashboard Still Editable
- Clear your browser cache
- Verify the `/api/instance/managed` endpoint returns `{"managed": true}`
- Check server logs for configuration errors
### Rate Limiting Not Working
- Ensure `HEMMELIG_ENABLE_RATE_LIMITING=true`
- Verify `HEMMELIG_RATE_LIMIT_REQUESTS` and `HEMMELIG_RATE_LIMIT_WINDOW` are set
- Rate limiting applies per IP address

85
docs/metrics.md Normal file
View File

@@ -0,0 +1,85 @@
# Prometheus Metrics
Hemmelig provides a Prometheus-compatible metrics endpoint for monitoring your instance.
## Enabling Metrics
1. Go to **Dashboard > Instance > Metrics** tab
2. Enable the **Enable Prometheus Metrics** toggle
3. Optionally, set a **Metrics Secret** for authentication
4. Save the settings
## Endpoint
```
GET /api/metrics
```
## Authentication
If a metrics secret is configured, you must include it as a Bearer token in the `Authorization` header:
```bash
curl -H "Authorization: Bearer YOUR_METRICS_SECRET" https://your-instance.com/api/metrics
```
If no secret is configured, the endpoint is accessible without authentication (not recommended for production).
## Available Metrics
### Application Metrics
| Metric | Type | Description |
| ---------------------------------------- | --------- | -------------------------------------------- |
| `hemmelig_secrets_active_count` | Gauge | Current number of active (unexpired) secrets |
| `hemmelig_users_total` | Gauge | Total number of registered users |
| `hemmelig_visitors_unique_30d` | Gauge | Unique visitors in the last 30 days |
| `hemmelig_visitors_views_30d` | Gauge | Total page views in the last 30 days |
| `hemmelig_http_request_duration_seconds` | Histogram | Duration of HTTP requests in seconds |
### Default Node.js Metrics
The endpoint also exposes default Node.js runtime metrics including:
- `nodejs_heap_size_total_bytes` - Process heap size
- `nodejs_heap_size_used_bytes` - Process heap size used
- `nodejs_external_memory_bytes` - Node.js external memory
- `nodejs_eventloop_lag_seconds` - Event loop lag
- `nodejs_active_handles_total` - Number of active handles
- `nodejs_active_requests_total` - Number of active requests
- `process_cpu_user_seconds_total` - User CPU time spent
- `process_cpu_system_seconds_total` - System CPU time spent
- `process_start_time_seconds` - Process start time
- `process_resident_memory_bytes` - Resident memory size
## Prometheus Configuration
Add the following job to your `prometheus.yml`:
```yaml
scrape_configs:
- job_name: 'hemmelig'
scrape_interval: 30s
static_configs:
- targets: ['your-instance.com']
metrics_path: '/api/metrics'
scheme: https
# If using authentication:
authorization:
type: Bearer
credentials: 'YOUR_METRICS_SECRET'
```
## Grafana Dashboard
You can create a Grafana dashboard to visualize these metrics. Here's an example panel query for active secrets:
```promql
hemmelig_secrets_active_count
```
## Security Considerations
- Always use a strong, randomly generated secret for the metrics endpoint in production
- Consider using network-level restrictions (firewall, VPN) to limit access to the metrics endpoint
- The metrics endpoint does not expose any sensitive data (secret contents, user data, etc.)

77
docs/sdk.md Normal file
View File

@@ -0,0 +1,77 @@
# SDK Generation
> **Disclaimer:** The Hemmelig API is subject to change without notice. Generated SDKs may break with future updates. Use at your own risk.
Hemmelig exposes an OpenAPI 3.0 specification that can be used to generate client SDKs in various programming languages.
## OpenAPI Specification
The OpenAPI spec is available at:
- **Swagger UI:** `/api/docs` - Interactive API explorer
- **OpenAPI JSON:** `/api/openapi.json` - Raw specification
## Generating an SDK
We recommend using [OpenAPI Generator](https://openapi-generator.tech/) which supports 50+ languages.
### Installation
```bash
npm install -g @openapitools/openapi-generator-cli
```
### Generate SDK
```bash
# TypeScript
openapi-generator-cli generate \
-i https://your-instance.com/api/openapi.json \
-g typescript-axios \
-o ./hemmelig-sdk
# Python
openapi-generator-cli generate \
-i https://your-instance.com/api/openapi.json \
-g python \
-o ./hemmelig-sdk
# Go
openapi-generator-cli generate \
-i https://your-instance.com/api/openapi.json \
-g go \
-o ./hemmelig-sdk
```
View all available generators:
```bash
openapi-generator-cli list
```
## Authentication
The API supports two authentication methods:
### Bearer Token (API Key)
```typescript
const client = new HemmeligApi({
baseURL: 'https://your-instance.com/api',
headers: {
Authorization: 'Bearer hemmelig_your_api_key_here',
},
});
```
### Session Cookie
For browser-based applications, session cookies are automatically handled after authentication via `/auth` endpoints.
## Important: Client-Side Encryption
Generated SDKs handle API communication only. **You must implement client-side encryption** before sending secrets to the API.
Hemmelig uses AES-256-GCM encryption. See the [encryption documentation](./encryption.md) for implementation details.
The decryption key should be passed via URL fragments (`#decryptionKey=...`) which are never sent to the server.

147
docs/secret-request.md Normal file
View File

@@ -0,0 +1,147 @@
# Secret Requests
Secret Requests allow you to request secrets from others securely. Instead of asking someone to create a secret and send you the link, you create a request link that they can use to submit a secret directly to you.
## How It Works
1. **Create a Request** - You configure the secret settings (expiration, max views, etc.) and get a unique request link
2. **Share the Link** - Send the request link to the person who has the secret
3. **They Submit** - They enter their secret, which gets encrypted in their browser. They receive a decryption key which they must send back to you.
4. **View the Secret** - You use the secret URL from your dashboard combined with the decryption key to view the secret.
```
┌─────────────┐ ┌─────────────┐
│ Requester │ │ Creator │
│ (You) │ │ (Them) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. Create request │
│───────────────────> │
│ │
│ 2. Share request link │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ > │
│ │
│ │ 3. Submit secret
│ │ (encrypted)
│ │
│ 4. They send you the │
│ decryption key │
│< ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ 5. View secret from │
│ dashboard with key │
│ │
```
## Creating a Request
Navigate to **Dashboard → Secret Requests → Create New Request**.
### Required Fields
- **Title** - A descriptive title shown to the creator (e.g., "API credentials for Project X")
### Optional Fields
- **Description** - Additional context for the creator
- **Link Validity** - How long the request link remains active (1 hour to 30 days)
- **Secret Expiration** - How long the submitted secret lives (5 minutes to 28 days)
- **Max Views** - Number of times the secret can be viewed (1-9999)
- **IP Restriction** - Limit secret access to specific IP/CIDR
- **Prevent Burn** - Keep secret even after max views reached
- **Webhook URL** - Get notified when the secret is submitted
## Webhooks
When a secret is submitted, Hemmelig sends a POST request to your webhook URL:
```json
{
"event": "secret_request.fulfilled",
"timestamp": "2024-12-14T10:30:00.000Z",
"request": {
"id": "uuid",
"title": "API credentials",
"createdAt": "2024-12-14T10:00:00.000Z",
"fulfilledAt": "2024-12-14T10:30:00.000Z"
},
"secret": {
"id": "secret-uuid",
"maxViews": 1,
"expiresAt": "2024-12-15T10:30:00.000Z"
}
}
```
### Webhook Security
Webhooks are signed using HMAC-SHA256. Verify the signature to ensure authenticity:
```javascript
const crypto = require('crypto');
function verifyWebhook(payload, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
return `sha256=${expectedSig}` === signature;
}
// Headers to check:
// X-Hemmelig-Signature: sha256=<hex>
// X-Hemmelig-Timestamp: <unix-timestamp>
```
**Note:** The webhook secret is shown only once when creating the request. Store it securely.
## Security
- **Client-side encryption** - Secrets are encrypted in the creator's browser before transmission
- **Decryption key in URL fragment** - The `#decryptionKey=...` never reaches the server
- **Single-use tokens** - Request links use 256-bit cryptographically secure tokens
- **Timing-safe validation** - Prevents timing attacks on token verification
## API Usage
### Create a Request
```bash
curl -X POST https://your-instance/api/secret-requests \
-H "Authorization: Bearer hemmelig_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Database credentials",
"description": "Need the prod DB password",
"maxViews": 1,
"expiresIn": 86400,
"validFor": 604800
}'
```
### List Your Requests
```bash
curl https://your-instance/api/secret-requests \
-H "Authorization: Bearer hemmelig_your_api_key"
```
### Get Request Details
```bash
curl https://your-instance/api/secret-requests/{id} \
-H "Authorization: Bearer hemmelig_your_api_key"
```
### Cancel a Request
```bash
curl -X DELETE https://your-instance/api/secret-requests/{id} \
-H "Authorization: Bearer hemmelig_your_api_key"
```
## Limits
- **Secret size**: 1 MB maximum
- **Title size**: 1 KB maximum
- **Request validity**: 1 hour to 30 days
- **Secret expiration**: 5 minutes to 28 days

451
docs/social-login.md Normal file
View File

@@ -0,0 +1,451 @@
# Social Login Configuration
Hemmelig supports multiple social login providers. Users can configure any combination of providers via environment variables. Only providers with valid credentials will be shown on the login and registration pages.
## Required Configuration
Before setting up any social provider, you must set your base URL:
```bash
HEMMELIG_BASE_URL=https://your-domain.com
```
This is used to generate the correct OAuth callback URLs.
## Supported Providers
### Standard Providers
| Provider | Environment Variables |
| --------- | -------------------------------------------------------------------------------------------------------------- |
| GitHub | `HEMMELIG_AUTH_GITHUB_ID`, `HEMMELIG_AUTH_GITHUB_SECRET` |
| Google | `HEMMELIG_AUTH_GOOGLE_ID`, `HEMMELIG_AUTH_GOOGLE_SECRET` |
| Microsoft | `HEMMELIG_AUTH_MICROSOFT_ID`, `HEMMELIG_AUTH_MICROSOFT_SECRET`, `HEMMELIG_AUTH_MICROSOFT_TENANT_ID` (optional) |
| Discord | `HEMMELIG_AUTH_DISCORD_ID`, `HEMMELIG_AUTH_DISCORD_SECRET` |
| GitLab | `HEMMELIG_AUTH_GITLAB_ID`, `HEMMELIG_AUTH_GITLAB_SECRET`, `HEMMELIG_AUTH_GITLAB_ISSUER` (optional) |
| Apple | `HEMMELIG_AUTH_APPLE_ID`, `HEMMELIG_AUTH_APPLE_SECRET` |
| Twitter/X | `HEMMELIG_AUTH_TWITTER_ID`, `HEMMELIG_AUTH_TWITTER_SECRET` |
### Generic OAuth Providers
Hemmelig now supports any OAuth 2.0 / OpenID Connect provider through the generic OAuth configuration. This allows you to integrate with identity providers like:
- **Authentik**
- **Authelia**
- **Keycloak**
- **Zitadel**
- **Ory Hydra**
- **Auth0** (if not using the built-in provider)
- **Okta**
- Any other OAuth 2.0 / OpenID Connect compatible provider
Use the `HEMMELIG_AUTH_GENERIC_OAUTH` environment variable with a JSON array of provider configurations.
**Environment Variable**: `HEMMELIG_AUTH_GENERIC_OAUTH`
#### Generic OAuth Configuration Format
Each provider in the array must include:
| Field | Required | Description |
| ------------------ | -------- | -------------------------------------------------------------------------- |
| `providerId` | Yes | Unique identifier for the provider (used in callback URLs) |
| `clientId` | Yes | OAuth client ID from your identity provider |
| `clientSecret` | Yes | OAuth client secret from your identity provider |
| `discoveryUrl` | No\* | OpenID Connect discovery URL (e.g., `/.well-known/openid-configuration`) |
| `authorizationUrl` | No\* | OAuth authorization endpoint (required if no `discoveryUrl`) |
| `tokenUrl` | No\* | OAuth token endpoint (required if no `discoveryUrl`) |
| `userInfoUrl` | No\* | OAuth user info endpoint (required if no `discoveryUrl`) |
| `scopes` | No | Array of OAuth scopes (default: `["openid", "profile", "email"]`) |
| `pkce` | No | Enable PKCE (Proof Key for Code Exchange) - recommended for public clients |
\*You must provide either `discoveryUrl` OR all three of (`authorizationUrl`, `tokenUrl`, `userInfoUrl`).
#### Example: Authentik
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}]
```
#### Example: Authelia
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authelia","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"your-client-secret","scopes":["openid","profile","email","groups"]}]
```
#### Example: Keycloak
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"keycloak","discoveryUrl":"https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}]
```
#### Example: Multiple Generic Providers
You can configure multiple generic OAuth providers in the same JSON array:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[
{
"providerId": "authentik",
"discoveryUrl": "https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration",
"clientId": "client-id-1",
"clientSecret": "secret-1",
"scopes": ["openid", "profile", "email"]
},
{
"providerId": "keycloak",
"discoveryUrl": "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
"clientId": "client-id-2",
"clientSecret": "secret-2"
}
]
```
#### Example: Manual URLs (without discovery)
If your provider doesn't support OpenID Connect discovery:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"custom","authorizationUrl":"https://oauth.example.com/authorize","tokenUrl":"https://oauth.example.com/token","userInfoUrl":"https://oauth.example.com/userinfo","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}]
```
## Callback URLs
When configuring your OAuth applications, use these callback URLs:
### Standard Providers
| Provider | Callback URL |
| --------- | ----------------------------------------------------- |
| GitHub | `https://your-domain.com/api/auth/callback/github` |
| Google | `https://your-domain.com/api/auth/callback/google` |
| Microsoft | `https://your-domain.com/api/auth/callback/microsoft` |
| Discord | `https://your-domain.com/api/auth/callback/discord` |
| GitLab | `https://your-domain.com/api/auth/callback/gitlab` |
| Apple | `https://your-domain.com/api/auth/callback/apple` |
| Twitter/X | `https://your-domain.com/api/auth/callback/twitter` |
### Generic OAuth Providers
For generic OAuth providers, the callback URL format is:
```
https://your-domain.com/api/auth/oauth2/callback/{providerId}
```
Where `{providerId}` is the value you specified in the `providerId` field.
**Examples:**
- Authentik: `https://your-domain.com/api/auth/oauth2/callback/authentik`
- Authelia: `https://your-domain.com/api/auth/oauth2/callback/authelia`
- Keycloak: `https://your-domain.com/api/auth/oauth2/callback/keycloak`
## Configuration
Add the environment variables for the providers you want to enable. Both `_ID` and `_SECRET` must be set for a standard provider to be enabled.
### Example: Docker Compose
```yaml
services:
hemmelig:
image: hemmelig/hemmelig:latest
environment:
# Required: Base URL for OAuth callbacks
- HEMMELIG_BASE_URL=https://your-domain.com
# Standard providers (GitHub example)
- HEMMELIG_AUTH_GITHUB_ID=your-github-client-id
- HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret
# Generic OAuth provider (Authentik example)
- HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}]
```
### Example: Environment File (.env)
```bash
# Required: Base URL for OAuth callbacks
HEMMELIG_BASE_URL=https://your-domain.com
# GitHub OAuth
HEMMELIG_AUTH_GITHUB_ID=your-github-client-id
HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret
# Google OAuth
HEMMELIG_AUTH_GOOGLE_ID=your-google-client-id
HEMMELIG_AUTH_GOOGLE_SECRET=your-google-client-secret
# Microsoft OAuth (Azure AD)
HEMMELIG_AUTH_MICROSOFT_ID=your-microsoft-client-id
HEMMELIG_AUTH_MICROSOFT_SECRET=your-microsoft-client-secret
HEMMELIG_AUTH_MICROSOFT_TENANT_ID=your-tenant-id # Optional, defaults to "common"
# Discord OAuth
HEMMELIG_AUTH_DISCORD_ID=your-discord-client-id
HEMMELIG_AUTH_DISCORD_SECRET=your-discord-client-secret
# GitLab OAuth
HEMMELIG_AUTH_GITLAB_ID=your-gitlab-client-id
HEMMELIG_AUTH_GITLAB_SECRET=your-gitlab-client-secret
HEMMELIG_AUTH_GITLAB_ISSUER=https://gitlab.example.com # Optional, for self-hosted GitLab
# Apple OAuth
HEMMELIG_AUTH_APPLE_ID=your-apple-client-id
HEMMELIG_AUTH_APPLE_SECRET=your-apple-client-secret
# Twitter/X OAuth
HEMMELIG_AUTH_TWITTER_ID=your-twitter-client-id
HEMMELIG_AUTH_TWITTER_SECRET=your-twitter-client-secret
# Generic OAuth (supports Authentik, Authelia, Keycloak, etc.)
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}]
```
## Setting Up OAuth Applications
### Standard Providers
#### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click "New OAuth App"
3. Set the callback URL to: `https://your-domain.com/api/auth/callback/github`
4. Copy the Client ID and Client Secret
#### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create a new OAuth 2.0 Client ID
3. Set the authorized redirect URI to: `https://your-domain.com/api/auth/callback/google`
4. Copy the Client ID and Client Secret
#### Microsoft (Azure AD)
1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)
2. Register a new application
3. Add a redirect URI: `https://your-domain.com/api/auth/callback/microsoft`
4. Create a client secret under "Certificates & secrets"
5. Copy the Application (client) ID and the client secret value
6. Optionally set the Tenant ID for single-tenant apps
#### Discord
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application
3. Go to OAuth2 settings
4. Add redirect URL: `https://your-domain.com/api/auth/callback/discord`
5. Copy the Client ID and Client Secret
#### GitLab
1. Go to GitLab User Settings > Applications
2. Create a new application
3. Set the redirect URI to: `https://your-domain.com/api/auth/callback/gitlab`
4. Select the `read_user` scope
5. Copy the Application ID and Secret
**Self-hosted GitLab:** If you're using a self-hosted GitLab instance, set the `HEMMELIG_AUTH_GITLAB_ISSUER` environment variable to your GitLab instance URL (e.g., `https://gitlab.example.com`). Without this, GitLab.com is used by default.
#### Apple
1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)
2. Create a Services ID
3. Configure Sign in with Apple, add your domain and return URL: `https://your-domain.com/api/auth/callback/apple`
4. Create a key for Sign in with Apple
5. Use the Services ID as Client ID and generate the client secret from the key
#### Twitter/X
1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)
2. Create a new project and app
3. Enable OAuth 2.0
4. Set the callback URL to: `https://your-domain.com/api/auth/callback/twitter`
5. Copy the Client ID and Client Secret
### Generic OAuth Providers
#### Authentik
1. Log into your Authentik instance as an admin
2. Go to **Applications** > **Providers** > **Create**
3. Select **OAuth2/OpenID Provider**
4. Configure the provider:
- **Name**: Hemmelig
- **Authorization flow**: Select your flow (e.g., default-authentication-flow)
- **Redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/authentik`
- **Client type**: Confidential
- **Scopes**: `openid`, `profile`, `email`
5. Save and copy the **Client ID** and **Client Secret**
6. Create an application and bind it to this provider
7. Find your discovery URL (usually `https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration`)
Example environment variable:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"<client-id>","clientSecret":"<client-secret>","scopes":["openid","profile","email"]}]
```
#### Authelia
1. Edit your Authelia configuration file (`configuration.yml`)
2. Add Hemmelig as a client under `identity_providers.oidc.clients`:
```yaml
clients:
- id: hemmelig
description: Hemmelig Secret Sharing
secret: <generate-a-secure-secret>
redirect_uris:
- https://your-domain.com/api/auth/oauth2/callback/authelia
scopes:
- openid
- profile
- email
- groups
```
3. Restart Authelia
4. Your discovery URL will be: `https://auth.example.com/.well-known/openid-configuration`
Example environment variable:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authelia","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"<client-secret>","scopes":["openid","profile","email"]}]
```
#### Keycloak
1. Log into your Keycloak admin console
2. Select your realm (or create a new one)
3. Go to **Clients** > **Create client**
4. Configure the client:
- **Client type**: OpenID Connect
- **Client ID**: `hemmelig`
5. On the next screen:
- **Client authentication**: ON
- **Valid redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/keycloak`
- **Web origins**: `https://your-domain.com`
6. Go to the **Credentials** tab and copy the **Client Secret**
7. Your discovery URL will be: `https://keycloak.example.com/realms/{realm-name}/.well-known/openid-configuration`
Example environment variable:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"keycloak","discoveryUrl":"https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"<client-secret>"}]
```
#### Zitadel
1. Log into your Zitadel instance
2. Go to your project (or create a new one)
3. Create a new application:
- **Type**: Web
- **Authentication method**: PKCE or Code
4. Configure:
- **Redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/zitadel`
- **Post logout redirect URIs**: `https://your-domain.com`
5. Copy the **Client ID** and **Client Secret** (if using Code flow)
6. Your discovery URL: `https://<instance>.zitadel.cloud/.well-known/openid-configuration`
Example environment variable:
```bash
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"zitadel","discoveryUrl":"https://instance.zitadel.cloud/.well-known/openid-configuration","clientId":"<client-id>","clientSecret":"<client-secret>","scopes":["openid","profile","email"],"pkce":true}]
```
## How It Works
1. On server startup, the application reads all `HEMMELIG_AUTH_*` environment variables
2. Only providers with both `_ID` and `_SECRET` set are enabled
3. The frontend fetches the list of enabled providers from `/api/config/social-providers`
4. Login and registration pages dynamically show buttons only for enabled providers
5. Each provider button uses the correct branded icon and colors
6. The callback URL is built using `HEMMELIG_BASE_URL` + `/api/auth/callback/{provider}`
## All Environment Variables
```bash
# Required for OAuth
HEMMELIG_BASE_URL=https://your-domain.com
# GitHub
HEMMELIG_AUTH_GITHUB_ID=
HEMMELIG_AUTH_GITHUB_SECRET=
# Google
HEMMELIG_AUTH_GOOGLE_ID=
HEMMELIG_AUTH_GOOGLE_SECRET=
# Microsoft (Azure AD)
HEMMELIG_AUTH_MICROSOFT_ID=
HEMMELIG_AUTH_MICROSOFT_SECRET=
HEMMELIG_AUTH_MICROSOFT_TENANT_ID= # Optional
# Discord
HEMMELIG_AUTH_DISCORD_ID=
HEMMELIG_AUTH_DISCORD_SECRET=
# GitLab
HEMMELIG_AUTH_GITLAB_ID=
HEMMELIG_AUTH_GITLAB_SECRET=
HEMMELIG_AUTH_GITLAB_ISSUER= # Optional, for self-hosted GitLab (e.g., https://gitlab.example.com)
# Apple
HEMMELIG_AUTH_APPLE_ID=
HEMMELIG_AUTH_APPLE_SECRET=
# Twitter/X
HEMMELIG_AUTH_TWITTER_ID=
HEMMELIG_AUTH_TWITTER_SECRET=
# Generic OAuth (JSON array - supports any OAuth 2.0 / OIDC provider)
HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"client-secret","scopes":["openid","profile","email"]}]
```
## Troubleshooting
### Provider not showing up
**Standard providers:**
- Ensure both `_ID` and `_SECRET` environment variables are set
- Restart the server after adding environment variables
- Check server logs for any configuration errors
**Generic OAuth providers:**
- Verify the JSON in `HEMMELIG_AUTH_GENERIC_OAUTH` is valid
- Check that each provider has `providerId`, `clientId`, and `clientSecret`
- Verify you have either `discoveryUrl` OR all three URLs (`authorizationUrl`, `tokenUrl`, `userInfoUrl`)
- Check server logs for parsing errors
### OAuth callback errors
**Standard providers:**
- Verify the callback URL in your OAuth app settings matches exactly
- Format: `https://your-domain.com/api/auth/callback/{provider}`
**Generic OAuth providers:**
- Callback URL format: `https://your-domain.com/api/auth/oauth2/callback/{providerId}`
- Ensure the `providerId` in your config matches the one in your identity provider settings
**Common issues:**
- Ensure `HEMMELIG_BASE_URL` is set correctly (no trailing slash)
- Ensure your domain is using HTTPS in production
- Check that the client ID and secret are correct (no extra spaces)
### "Access Denied" errors
- Verify the OAuth app has the correct permissions/scopes
- For Microsoft, ensure the app is configured for the correct account types
- For Apple, ensure the Services ID is correctly configured
- For generic OAuth: Check that the requested scopes are allowed by your provider
### Discovery URL errors (Generic OAuth)
- Verify the discovery URL is accessible: `curl https://your-auth-provider/.well-known/openid-configuration`
- Ensure your Hemmelig instance can reach the discovery URL (check firewall rules)
- Try using manual URLs (`authorizationUrl`, `tokenUrl`, `userInfoUrl`) instead if discovery is not supported

33
docs/upgrade.md Normal file
View File

@@ -0,0 +1,33 @@
# Upgrading from v6 to v7
## ⚠️ Breaking Changes - Fresh Start Required
**v7 is a complete rewrite and is NOT backwards compatible with v6.** Due to fundamental changes in the encryption and password hashing algorithms, migration of existing data is not possible.
### What Changed
| Component | v6 | v7 |
| -------------------- | ------------------- | ----------------------- |
| Encryption Algorithm | Different algorithm | AES-256-GCM with PBKDF2 |
| Password Hashing | Different algorithm | Updated secure hashing |
| Database Schema | Previous schema | New schema structure |
### Why Migration Is Not Possible
1. **Encryption Algorithm Change:** Secrets encrypted with the v6 algorithm cannot be decrypted with v7's implementation. Since the server never stores decryption keys (zero-knowledge architecture), there is no way to re-encrypt existing secrets.
2. **Password Algorithm Change:** User passwords are hashed differently in v7. Existing password hashes from v6 cannot be verified or converted.
### Upgrade Steps
1. **Backup v6 data** (for reference only - it cannot be migrated)
2. **Stop your v6 instance**
3. **Deploy v7 with a fresh database** - See [Docker Guide](docker.md) for deployment instructions
4. **Re-create user accounts**
5. **Inform users** that existing secrets are no longer accessible
### Important Notes
- No migration scripts are provided or planned
- Users must register new accounts in v7
- Consider running v6 in read-only mode temporarily to allow users to retrieve unexpired secrets before shutdown

230
docs/webhook.md Normal file
View File

@@ -0,0 +1,230 @@
# Webhook Notifications
Hemmelig can send HTTP POST requests to your webhook URL when secrets are viewed or burned. This allows you to integrate with external services like Slack, Discord, monitoring systems, or custom applications.
## Configuration
Configure webhooks in the admin dashboard under **Instance Settings → Webhooks**.
| Setting | Description |
| ------------------- | ------------------------------------------ |
| **Enable Webhooks** | Turn webhook notifications on/off |
| **Webhook URL** | The endpoint where payloads are sent |
| **Webhook Secret** | Secret key for HMAC-SHA256 payload signing |
| **Secret Viewed** | Send webhook when a secret is viewed |
| **Secret Burned** | Send webhook when a secret is deleted |
## Webhook Payload
Webhooks are sent as HTTP POST requests with a JSON body:
### Secret Events
```json
{
"event": "secret.viewed",
"timestamp": "2024-12-04T10:30:00.000Z",
"data": {
"secretId": "abc123-def456",
"hasPassword": true,
"hasIpRestriction": false,
"viewsRemaining": 2
}
}
```
### API Key Events
```json
{
"event": "apikey.created",
"timestamp": "2024-12-04T10:30:00.000Z",
"data": {
"apiKeyId": "key-uuid-here",
"name": "My Integration",
"expiresAt": "2025-12-04T10:30:00.000Z",
"userId": "user-uuid-here"
}
}
```
### Event Types
| Event | Description |
| ---------------- | -------------------------------------------------- |
| `secret.viewed` | A secret was successfully viewed |
| `secret.burned` | A secret was deleted (manually or after last view) |
| `apikey.created` | A new API key was created |
### Headers
| Header | Description |
| ---------------------- | ------------------------------------------------------------------ |
| `Content-Type` | `application/json` |
| `X-Hemmelig-Event` | Event type (`secret.viewed`, `secret.burned`, or `apikey.created`) |
| `X-Hemmelig-Signature` | HMAC-SHA256 signature (if secret configured) |
## Verifying Webhook Signatures
If you configure a webhook secret, Hemmelig signs each payload using HMAC-SHA256. The signature is sent in the `X-Hemmelig-Signature` header as `sha256=<hex>`.
**Always verify signatures** to ensure webhooks are authentic and haven't been tampered with.
### Node.js Example
```javascript
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Express.js middleware
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-hemmelig-signature'];
const payload = req.body.toString();
if (!verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
console.log(`Received ${event.event} for secret ${event.data.secretId}`);
res.status(200).send('OK');
});
```
### Python Example
```python
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Hemmelig-Signature')
payload = request.get_data()
if not verify_webhook(payload, signature, os.environ['WEBHOOK_SECRET']):
return 'Invalid signature', 401
event = request.get_json()
print(f"Received {event['event']} for secret {event['data']['secretId']}")
return 'OK', 200
```
### Go Example
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func verifyWebhook(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Hemmelig-Signature")
payload, _ := io.ReadAll(r.Body)
if !verifyWebhook(payload, signature, os.Getenv("WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process webhook...
w.WriteHeader(http.StatusOK)
}
```
## Integration Examples
### Slack Notification
Send a message to Slack when a secret is viewed:
```javascript
app.post('/webhook', async (req, res) => {
const event = req.body;
if (event.event === 'secret.viewed') {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🔓 Secret ${event.data.secretId} was viewed. ${event.data.viewsRemaining} views remaining.`,
}),
});
}
res.status(200).send('OK');
});
```
### Discord Notification
```javascript
if (event.event === 'secret.burned') {
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
{
title: '🔥 Secret Burned',
description: `Secret \`${event.data.secretId}\` has been permanently deleted.`,
color: 0xff6b6b,
timestamp: event.timestamp,
},
],
}),
});
}
```
## Best Practices
1. **Always use HTTPS** for your webhook endpoint
2. **Always verify signatures** to prevent spoofed requests
3. **Respond quickly** (< 5 seconds) to avoid timeouts
4. **Use a queue** for heavy processing to avoid blocking
5. **Log webhook events** for debugging and audit trails
6. **Handle retries** gracefully (webhooks are fire-and-forget, no retries)
## Troubleshooting
**Webhooks not being received?**
- Check that webhooks are enabled in Instance Settings
- Verify your webhook URL is accessible from the Hemmelig server
- Check server logs for any error messages
**Invalid signature errors?**
- Ensure you're using the raw request body (not parsed JSON) for verification
- Check that your webhook secret matches exactly
- Make sure you're comparing the full signature including the `sha256=` prefix

25
eslint.config.js Normal file
View File

@@ -0,0 +1,25 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
);

18
helm/hemmelig/.helmignore Normal file
View File

@@ -0,0 +1,18 @@
# Patterns to ignore when building packages.
.DS_Store
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/

17
helm/hemmelig/Chart.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: v2
name: hemmelig
description: A Helm chart for Hemmelig - Encrypted secret sharing application
type: application
version: 1.0.0
appVersion: "7.0.0"
keywords:
- secrets
- encryption
- security
- sharing
home: https://hemmelig.app
sources:
- https://github.com/HemmeligOrg/Hemmelig.app
maintainers:
- name: HemmeligOrg
url: https://github.com/HemmeligOrg

View File

@@ -0,0 +1,38 @@
Thank you for installing {{ .Chart.Name }}!
{{- if .Values.ingress.enabled }}
Your Hemmelig instance is available at:
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
Get the application URL by running:
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hemmelig.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
Get the application URL by running:
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hemmelig.fullname" . }}
{{- else if contains "ClusterIP" .Values.service.type }}
Get the application URL by running:
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "hemmelig.fullname" . }} 3000:{{ .Values.service.port }}
echo "Visit http://127.0.0.1:3000"
{{- end }}
{{- if not .Values.config.betterAuthSecret }}
WARNING: You have not set config.betterAuthSecret!
Please set this value or use existingSecret to provide authentication secrets.
Generate a secret with: openssl rand -base64 32
{{- end }}
{{- if not .Values.config.betterAuthUrl }}
WARNING: You have not set config.betterAuthUrl!
This is required for OAuth authentication and proper cookie handling.
{{- end }}
For more information, visit: https://github.com/HemmeligOrg/Hemmelig.app

View File

@@ -0,0 +1,60 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "hemmelig.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "hemmelig.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "hemmelig.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "hemmelig.labels" -}}
helm.sh/chart: {{ include "hemmelig.chart" . }}
{{ include "hemmelig.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "hemmelig.selectorLabels" -}}
app.kubernetes.io/name: {{ include "hemmelig.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "hemmelig.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "hemmelig.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,318 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hemmelig.fullname" . }}
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: Recreate
selector:
matchLabels:
{{- include "hemmelig.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hemmelig.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "hemmelig.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
value: "file:/app/database/hemmelig.db"
{{- if .Values.config.betterAuthUrl }}
- name: BETTER_AUTH_URL
value: {{ .Values.config.betterAuthUrl | quote }}
{{- end }}
{{- if .Values.config.baseUrl }}
- name: HEMMELIG_BASE_URL
value: {{ .Values.config.baseUrl | quote }}
{{- end }}
{{- if .Values.existingSecret }}
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: BETTER_AUTH_SECRET
{{- else if .Values.config.betterAuthSecret }}
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: BETTER_AUTH_SECRET
{{- end }}
{{- if or (.Values.oauth.github.enabled) (.Values.oauth.google.enabled) (.Values.oauth.microsoft.enabled) (.Values.oauth.discord.enabled) (.Values.oauth.gitlab.enabled) (.Values.oauth.apple.enabled) (.Values.oauth.twitter.enabled) (.Values.oauth.generic) }}
{{- if .Values.existingSecret }}
# OAuth variables from existing secret
{{- if .Values.oauth.github.enabled }}
- name: HEMMELIG_AUTH_GITHUB_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GITHUB_ID
- name: HEMMELIG_AUTH_GITHUB_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GITHUB_SECRET
{{- end }}
{{- if .Values.oauth.google.enabled }}
- name: HEMMELIG_AUTH_GOOGLE_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GOOGLE_ID
- name: HEMMELIG_AUTH_GOOGLE_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GOOGLE_SECRET
{{- end }}
{{- if .Values.oauth.microsoft.enabled }}
- name: HEMMELIG_AUTH_MICROSOFT_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_MICROSOFT_ID
- name: HEMMELIG_AUTH_MICROSOFT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_MICROSOFT_SECRET
- name: HEMMELIG_AUTH_MICROSOFT_TENANT_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_MICROSOFT_TENANT_ID
optional: true
{{- end }}
{{- if .Values.oauth.discord.enabled }}
- name: HEMMELIG_AUTH_DISCORD_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_DISCORD_ID
- name: HEMMELIG_AUTH_DISCORD_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_DISCORD_SECRET
{{- end }}
{{- if .Values.oauth.gitlab.enabled }}
- name: HEMMELIG_AUTH_GITLAB_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GITLAB_ID
- name: HEMMELIG_AUTH_GITLAB_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GITLAB_SECRET
- name: HEMMELIG_AUTH_GITLAB_ISSUER
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GITLAB_ISSUER
optional: true
{{- end }}
{{- if .Values.oauth.apple.enabled }}
- name: HEMMELIG_AUTH_APPLE_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_APPLE_ID
- name: HEMMELIG_AUTH_APPLE_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_APPLE_SECRET
{{- end }}
{{- if .Values.oauth.twitter.enabled }}
- name: HEMMELIG_AUTH_TWITTER_ID
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_TWITTER_ID
- name: HEMMELIG_AUTH_TWITTER_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_TWITTER_SECRET
{{- end }}
{{- if .Values.oauth.generic }}
- name: HEMMELIG_AUTH_GENERIC_OAUTH
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecret }}
key: HEMMELIG_AUTH_GENERIC_OAUTH
{{- end }}
{{- else }}
# OAuth variables from default secret (when not using existingSecret)
{{- if .Values.oauth.github.enabled }}
- name: HEMMELIG_AUTH_GITHUB_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GITHUB_ID
- name: HEMMELIG_AUTH_GITHUB_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GITHUB_SECRET
{{- end }}
{{- if .Values.oauth.google.enabled }}
- name: HEMMELIG_AUTH_GOOGLE_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GOOGLE_ID
- name: HEMMELIG_AUTH_GOOGLE_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GOOGLE_SECRET
{{- end }}
{{- if .Values.oauth.microsoft.enabled }}
- name: HEMMELIG_AUTH_MICROSOFT_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_MICROSOFT_ID
- name: HEMMELIG_AUTH_MICROSOFT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_MICROSOFT_SECRET
- name: HEMMELIG_AUTH_MICROSOFT_TENANT_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_MICROSOFT_TENANT_ID
optional: true
{{- end }}
{{- if .Values.oauth.discord.enabled }}
- name: HEMMELIG_AUTH_DISCORD_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_DISCORD_ID
- name: HEMMELIG_AUTH_DISCORD_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_DISCORD_SECRET
{{- end }}
{{- if .Values.oauth.gitlab.enabled }}
- name: HEMMELIG_AUTH_GITLAB_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GITLAB_ID
- name: HEMMELIG_AUTH_GITLAB_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GITLAB_SECRET
- name: HEMMELIG_AUTH_GITLAB_ISSUER
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GITLAB_ISSUER
optional: true
{{- end }}
{{- if .Values.oauth.apple.enabled }}
- name: HEMMELIG_AUTH_APPLE_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_APPLE_ID
- name: HEMMELIG_AUTH_APPLE_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_APPLE_SECRET
{{- end }}
{{- if .Values.oauth.twitter.enabled }}
- name: HEMMELIG_AUTH_TWITTER_ID
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_TWITTER_ID
- name: HEMMELIG_AUTH_TWITTER_SECRET
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_TWITTER_SECRET
{{- end }}
{{- if .Values.oauth.generic }}
- name: HEMMELIG_AUTH_GENERIC_OAUTH
valueFrom:
secretKeyRef:
name: {{ include "hemmelig.fullname" . }}
key: HEMMELIG_AUTH_GENERIC_OAUTH
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: data
mountPath: /app/database
- name: uploads
mountPath: /app/uploads
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: data
{{- if .Values.persistence.data.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.data.existingClaim | default (printf "%s-data" (include "hemmelig.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
- name: uploads
{{- if .Values.persistence.uploads.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.uploads.existingClaim | default (printf "%s-uploads" (include "hemmelig.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "hemmelig.fullname" . }}
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "hemmelig.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,35 @@
{{- if and .Values.persistence.data.enabled (not .Values.persistence.data.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "hemmelig.fullname" . }}-data
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.data.accessMode }}
{{- if .Values.persistence.data.storageClass }}
storageClassName: {{ .Values.persistence.data.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.uploads.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "hemmelig.fullname" . }}-uploads
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.uploads.accessMode }}
{{- if .Values.persistence.uploads.storageClass }}
storageClassName: {{ .Values.persistence.uploads.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
{{- end }}

View File

@@ -0,0 +1,78 @@
{{- if and (or .Values.config.betterAuthSecret (or .Values.oauth.github.enabled (or .Values.oauth.google.enabled (or .Values.oauth.microsoft.enabled (or .Values.oauth.discord.enabled (or .Values.oauth.gitlab.enabled (or .Values.oauth.apple.enabled (or .Values.oauth.twitter.enabled .Values.oauth.generic)))))))) (not .Values.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "hemmelig.fullname" . }}
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
type: Opaque
data:
{{- if .Values.config.betterAuthSecret }}
BETTER_AUTH_SECRET: {{ .Values.config.betterAuthSecret | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.github.enabled }}
{{- if .Values.oauth.github.clientId }}
HEMMELIG_AUTH_GITHUB_ID: {{ .Values.oauth.github.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.github.clientSecret }}
HEMMELIG_AUTH_GITHUB_SECRET: {{ .Values.oauth.github.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.google.enabled }}
{{- if .Values.oauth.google.clientId }}
HEMMELIG_AUTH_GOOGLE_ID: {{ .Values.oauth.google.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.google.clientSecret }}
HEMMELIG_AUTH_GOOGLE_SECRET: {{ .Values.oauth.google.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.microsoft.enabled }}
{{- if .Values.oauth.microsoft.clientId }}
HEMMELIG_AUTH_MICROSOFT_ID: {{ .Values.oauth.microsoft.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.microsoft.clientSecret }}
HEMMELIG_AUTH_MICROSOFT_SECRET: {{ .Values.oauth.microsoft.clientSecret | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.microsoft.tenantId }}
HEMMELIG_AUTH_MICROSOFT_TENANT_ID: {{ .Values.oauth.microsoft.tenantId | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.discord.enabled }}
{{- if .Values.oauth.discord.clientId }}
HEMMELIG_AUTH_DISCORD_ID: {{ .Values.oauth.discord.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.discord.clientSecret }}
HEMMELIG_AUTH_DISCORD_SECRET: {{ .Values.oauth.discord.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.gitlab.enabled }}
{{- if .Values.oauth.gitlab.clientId }}
HEMMELIG_AUTH_GITLAB_ID: {{ .Values.oauth.gitlab.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.gitlab.clientSecret }}
HEMMELIG_AUTH_GITLAB_SECRET: {{ .Values.oauth.gitlab.clientSecret | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.gitlab.issuer }}
HEMMELIG_AUTH_GITLAB_ISSUER: {{ .Values.oauth.gitlab.issuer | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.apple.enabled }}
{{- if .Values.oauth.apple.clientId }}
HEMMELIG_AUTH_APPLE_ID: {{ .Values.oauth.apple.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.apple.clientSecret }}
HEMMELIG_AUTH_APPLE_SECRET: {{ .Values.oauth.apple.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.twitter.enabled }}
{{- if .Values.oauth.twitter.clientId }}
HEMMELIG_AUTH_TWITTER_ID: {{ .Values.oauth.twitter.clientId | b64enc | quote }}
{{- end }}
{{- if .Values.oauth.twitter.clientSecret }}
HEMMELIG_AUTH_TWITTER_SECRET: {{ .Values.oauth.twitter.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- if .Values.oauth.generic }}
HEMMELIG_AUTH_GENERIC_OAUTH: {{ .Values.oauth.generic | b64enc | quote }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "hemmelig.fullname" . }}
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hemmelig.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "hemmelig.serviceAccountName" . }}
labels:
{{- include "hemmelig.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

153
helm/hemmelig/values.yaml Normal file
View File

@@ -0,0 +1,153 @@
# Default values for hemmelig
replicaCount: 1
image:
repository: hemmeligapp/hemmelig
tag: "v7"
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Required configuration
config:
# Generate with: openssl rand -base64 32
betterAuthSecret: ""
# Public URL of your instance (required for OAuth and cookie handling)
betterAuthUrl: ""
# Base URL for OAuth callbacks (required for social login)
baseUrl: ""
# Additional environment variables
env: []
# - name: HEMMELIG_ANALYTICS_ENABLED
# value: "true"
# Use existing secret for sensitive values
existingSecret: ""
# Keys expected in the secret:
# BETTER_AUTH_SECRET
# HEMMELIG_ANALYTICS_HMAC_SECRET (optional)
# HEMMELIG_AUTH_GITHUB_ID, HEMMELIG_AUTH_GITHUB_SECRET (optional)
# HEMMELIG_AUTH_GOOGLE_ID, HEMMELIG_AUTH_GOOGLE_SECRET (optional)
# HEMMELIG_AUTH_MICROSOFT_ID, HEMMELIG_AUTH_MICROSOFT_SECRET, HEMMELIG_AUTH_MICROSOFT_TENANT_ID (optional)
# HEMMELIG_AUTH_DISCORD_ID, HEMMELIG_AUTH_DISCORD_SECRET (optional)
# HEMMELIG_AUTH_GITLAB_ID, HEMMELIG_AUTH_GITLAB_SECRET, HEMMELIG_AUTH_GITLAB_ISSUER (optional)
# HEMMELIG_AUTH_APPLE_ID, HEMMELIG_AUTH_APPLE_SECRET (optional)
# HEMMELIG_AUTH_TWITTER_ID, HEMMELIG_AUTH_TWITTER_SECRET (optional)
# HEMMELIG_AUTH_GENERIC_OAUTH (optional)
# OAuth / Social Login Configuration
oauth:
github:
enabled: false
clientId: ""
clientSecret: ""
google:
enabled: false
clientId: ""
clientSecret: ""
microsoft:
enabled: false
clientId: ""
clientSecret: ""
tenantId: "" # Optional
discord:
enabled: false
clientId: ""
clientSecret: ""
gitlab:
enabled: false
clientId: ""
clientSecret: ""
issuer: "" # Optional, for self-hosted GitLab (e.g., https://gitlab.example.com)
apple:
enabled: false
clientId: ""
clientSecret: ""
twitter:
enabled: false
clientId: ""
clientSecret: ""
generic: ""
# Example: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]'
serviceAccount:
create: true
automount: true
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
service:
type: ClusterIP
port: 3000
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: hemmelig.local
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: hemmelig-tls
# hosts:
# - hemmelig.local
resources: {}
# limits:
# cpu: 500m
# memory: 512Mi
# requests:
# cpu: 100m
# memory: 128Mi
# Persistence for SQLite database and uploads
persistence:
data:
enabled: true
size: 1Gi
storageClass: ""
accessMode: ReadWriteOnce
# existingClaim: ""
uploads:
enabled: true
size: 5Gi
storageClass: ""
accessMode: ReadWriteOnce
# existingClaim: ""
livenessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
nodeSelector: {}
tolerations: []
affinity: {}

51
index.html Normal file
View File

@@ -0,0 +1,51 @@
<!doctype html>
<html lang="es" class="dark">
<head>
<meta charset="utf-8" />
<title>paste.es - Comparte secretos de forma segura</title>
<link rel="icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<!-- Primary Meta Tags -->
<meta name="title" content="paste.es - Comparte secretos de forma segura" />
<meta
name="description"
content="Comparte secretos de forma segura con mensajes cifrados que se autodestruyen tras ser leídos. Sin recopilación de datos, sin rastreo."
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://paste.es/" />
<meta property="og:title" content="paste.es - Comparte secretos de forma segura" />
<meta
property="og:description"
content="Comparte secretos de forma segura con mensajes cifrados que se autodestruyen tras ser leídos. Sin recopilación de datos, sin rastreo."
/>
<meta property="og:image" content="/icons/icon-512x512.png" />
<!-- Twitter -->
<meta property="twitter:card" content="/summary_large_image" />
<meta property="twitter:url" content="https://paste.es/" />
<meta property="twitter:title" content="paste.es - Comparte secretos de forma segura" />
<meta
property="twitter:description"
content="Comparte secretos de forma segura con mensajes cifrados que se autodestruyen tras ser leídos. Sin recopilación de datos, sin rastreo."
/>
<meta property="twitter:image" content="/icons/icon-512x512.png" />
<meta name="theme-color" content="#231e23" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icons/maskable-icon-192x192.png" />
</head>
<body>
<noscript>Necesitas activar JavaScript para usar esta aplicación.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More