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:
56
.dockerignore
Normal file
56
.dockerignore
Normal 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
9
.editorconfig
Normal 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
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @bjarneo
|
||||
25
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
26
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal 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
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
22
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal 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
168
.github/workflows/cli-release.yml
vendored
Normal 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.
|
||||
59
.github/workflows/publish_docker_image.yaml
vendored
Normal file
59
.github/workflows/publish_docker_image.yaml
vendored
Normal 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
108
.github/workflows/release.yml
vendored
Normal 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
51
.github/workflows/trivy.yaml
vendored
Normal 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
139
.gitignore
vendored
Normal 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
1
.husky/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
_
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
9
.npmignore
Normal file
9
.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
src/client
|
||||
src/server
|
||||
src/*.js
|
||||
server.js
|
||||
config/
|
||||
public/
|
||||
.github/
|
||||
tests/
|
||||
hemmelig.backup.db
|
||||
11
.prettierignore
Normal file
11
.prettierignore
Normal 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
10
.prettierrc
Normal 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
640
CLAUDE.md
Normal 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
72
Dockerfile
Normal 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
8
LICENSE
Normal 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
135
README.md
Normal 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
158
api/app.ts
Normal 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
176
api/auth.ts
Normal 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
255
api/config.ts
Normal 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
67
api/jobs/expired.ts
Normal 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
14
api/jobs/index.ts
Normal 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
62
api/lib/analytics.ts
Normal 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
85
api/lib/constants.ts
Normal 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
19
api/lib/db.ts
Normal 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
77
api/lib/files.ts
Normal 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
49
api/lib/password.ts
Normal 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
40
api/lib/settings.ts
Normal 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
130
api/lib/utils.ts
Normal 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
106
api/lib/webhook.ts
Normal 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
91
api/middlewares/auth.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
33
api/middlewares/ip-restriction.ts
Normal file
33
api/middlewares/ip-restriction.ts
Normal 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();
|
||||
};
|
||||
32
api/middlewares/ratelimit.ts
Normal file
32
api/middlewares/ratelimit.ts
Normal 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
1568
api/openapi.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
api/routes.ts
Normal file
48
api/routes.ts
Normal 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
130
api/routes/account.ts
Normal 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
253
api/routes/analytics.ts
Normal 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
156
api/routes/api-keys.ts
Normal 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
134
api/routes/files.ts
Normal 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
131
api/routes/health.ts
Normal 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
169
api/routes/instance.ts
Normal 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
151
api/routes/invites.ts
Normal 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
159
api/routes/metrics.ts
Normal 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;
|
||||
455
api/routes/secret-requests.ts
Normal file
455
api/routes/secret-requests.ts
Normal 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
343
api/routes/secrets.ts
Normal 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
82
api/routes/setup.ts
Normal 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
89
api/routes/user.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
34
api/validations/account.ts
Normal file
34
api/validations/account.ts
Normal 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'],
|
||||
});
|
||||
60
api/validations/instance.ts
Normal file
60
api/validations/instance.ts
Normal 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(),
|
||||
});
|
||||
45
api/validations/password.ts
Normal file
45
api/validations/password.ts
Normal 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);
|
||||
122
api/validations/secret-requests.ts
Normal file
122
api/validations/secret-requests.ts
Normal 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
120
api/validations/secrets.ts
Normal 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
22
api/validations/user.ts
Normal 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
BIN
banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
7
cli-go/.gitignore
vendored
Normal file
7
cli-go/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Binary
|
||||
hemmelig
|
||||
hemmelig-*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
126
cli-go/README.md
Normal file
126
cli-go/README.md
Normal 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
5
cli-go/go.mod
Normal 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
2
cli-go/go.sum
Normal 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
343
cli-go/main.go
Normal 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
146
cli/README.md
Normal 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
54
cli/package-lock.json
generated
Normal 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
54
cli/package.json
Normal 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
197
cli/src/bin.ts
Normal 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
210
cli/src/index.ts
Normal 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
19
cli/tsconfig.json
Normal 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
30
docker-compose.yml
Normal 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
106
docs/api.md
Normal 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
374
docs/cli.md
Normal 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
267
docs/docker.md
Normal 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
178
docs/e2e.md
Normal 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
123
docs/encryption.md
Normal 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
186
docs/env.md
Normal 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
95
docs/health.md
Normal 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
141
docs/helm-oauth.md
Normal 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
205
docs/helm.md
Normal 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
299
docs/managed.md
Normal 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
85
docs/metrics.md
Normal 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
77
docs/sdk.md
Normal 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
147
docs/secret-request.md
Normal 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
451
docs/social-login.md
Normal 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
33
docs/upgrade.md
Normal 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
230
docs/webhook.md
Normal 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
25
eslint.config.js
Normal 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
18
helm/hemmelig/.helmignore
Normal 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
17
helm/hemmelig/Chart.yaml
Normal 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
|
||||
38
helm/hemmelig/templates/NOTES.txt
Normal file
38
helm/hemmelig/templates/NOTES.txt
Normal 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
|
||||
60
helm/hemmelig/templates/_helpers.tpl
Normal file
60
helm/hemmelig/templates/_helpers.tpl
Normal 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 }}
|
||||
318
helm/hemmelig/templates/deployment.yaml
Normal file
318
helm/hemmelig/templates/deployment.yaml
Normal 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 }}
|
||||
41
helm/hemmelig/templates/ingress.yaml
Normal file
41
helm/hemmelig/templates/ingress.yaml
Normal 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 }}
|
||||
35
helm/hemmelig/templates/pvc.yaml
Normal file
35
helm/hemmelig/templates/pvc.yaml
Normal 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 }}
|
||||
78
helm/hemmelig/templates/secret.yaml
Normal file
78
helm/hemmelig/templates/secret.yaml
Normal 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 }}
|
||||
15
helm/hemmelig/templates/service.yaml
Normal file
15
helm/hemmelig/templates/service.yaml
Normal 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 }}
|
||||
13
helm/hemmelig/templates/serviceaccount.yaml
Normal file
13
helm/hemmelig/templates/serviceaccount.yaml
Normal 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
153
helm/hemmelig/values.yaml
Normal 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
51
index.html
Normal 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
Reference in New Issue
Block a user