This document covers building, testing, and contributing to Snipo.
- Go 1.25+
- Make (optional, for convenience commands)
- Docker (optional, for containerized builds)
# Build binary
make build
# Output: ./bin/snipo
# Or directly with Go
go build -o bin/snipo ./cmd/server# Build local image
make docker
# Or with docker build
docker build -t snipo:local .
# Build with version info
docker build \
--build-arg VERSION=v1.0.0 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t snipo:v1.0.0 .The Docker deployment implements multiple security layers:
Container Security:
- Non-root user: Runs as UID 1000 (
snipouser) - Read-only root filesystem: Prevents tampering with system files
- Dropped capabilities: All Linux capabilities removed (
cap_drop: ALL) - No privilege escalation:
no-new-privileges:trueprevents gaining elevated privileges - Minimal base image: Alpine Linux 3.20 with only essential packages
- Hardened Variant: Available via
:hardenedtag, running as UID 65532 (nonroot) with even fewer packages (no shell, no apk) based on DHI images.
Filesystem Security:
- Binary owned by root with 755 permissions (executable but not writable)
- Data directory (
/data) owned by snipo user - Temporary storage via tmpfs (10MB limit, automatically cleared)
- Volume mount for persistent data only
Network Security:
- No privileged ports required (uses 8080)
- Container-to-container isolation via Docker networks
- CORS configuration for cross-origin access control
Resource Limits: You can add resource constraints in docker-compose.yml:
services:
snipo:
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M- Use strong
SNIPO_MASTER_PASSWORD(16+ characters, mixed case, numbers, symbols) - Generate random
SNIPO_SESSION_SECRET(useopenssl rand -hex 32) - Enable HTTPS (use reverse proxy like Nginx/Caddy/Traefik)
- Configure
SNIPO_TRUST_PROXY=trueif behind proxy - Set restrictive
SNIPO_ALLOWED_ORIGINSfor CORS - Use Docker secrets for sensitive environment variables
- Enable S3 backups with encryption
- Set up monitoring and health checks
- Configure log aggregation (
SNIPO_LOG_FORMAT=json) - Keep Docker image updated regularly
- Review and adjust rate limits based on usage
Instead of plain environment variables, use Docker secrets:
services:
snipo:
secrets:
- snipo_password
- snipo_session_secret
environment:
- SNIPO_MASTER_PASSWORD_FILE=/run/secrets/snipo_password
- SNIPO_SESSION_SECRET_FILE=/run/secrets/snipo_session_secret
secrets:
snipo_password:
file: ./secrets/password.txt
snipo_session_secret:
file: ./secrets/session_secret.txt# Run with hot reload (requires air: go install github.com/air-verse/air@latest)
make dev
# Or run directly
export SNIPO_MASTER_PASSWORD="dev-password"
export SNIPO_SESSION_SECRET="dev-secret-at-least-32-characters-long" ## Generate it with: "openssl rand -hex 32
go run ./cmd/server serve# Copy example environment
cp .env.example .env
# Edit .env with your settings
docker compose up -d# Run all tests
make test
# Run with coverage
make coverage ## we have poor coverage right now, you are welcome to improve it
# Run specific package tests
go test -v ./internal/api/handlers/...
# Run with race detection
go test -race ./...# Run linter (requires golangci-lint) - all contributions must pass this
make lint
# Install golangci-lint
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latestAll configuration is via environment variables. See .env.example for defaults.
| Variable | Default | Description |
|---|---|---|
SNIPO_HOST |
0.0.0.0 |
Server bind address |
SNIPO_PORT |
8080 |
Server port |
SNIPO_DB_PATH |
/data/snipo.db |
SQLite database path |
SNIPO_MASTER_PASSWORD |
required | Login password |
SNIPO_SESSION_SECRET |
required | Session signing key (32+ chars) |
SNIPO_SESSION_DURATION |
168h |
Session lifetime |
SNIPO_TRUST_PROXY |
false |
Trust X-Forwarded-For headers |
| Variable | Default | Description |
|---|---|---|
SNIPO_RATE_LIMIT |
100 |
Login requests per window |
SNIPO_RATE_WINDOW |
1m |
Rate limit window duration |
SNIPO_RATE_LIMIT_READ |
1000 |
API read operations (per hour) |
SNIPO_RATE_LIMIT_WRITE |
500 |
API write operations (per hour) |
SNIPO_RATE_LIMIT_ADMIN |
100 |
API admin operations (per hour) |
| Variable | Default | Description |
|---|---|---|
SNIPO_ALLOWED_ORIGINS |
- | CORS allowed origins (comma-separated), use * for dev |
SNIPO_ENABLE_PUBLIC_SNIPPETS |
true |
Enable public snippet sharing |
SNIPO_ENABLE_API_TOKENS |
true |
Enable API token creation |
SNIPO_ENABLE_BACKUP_RESTORE |
true |
Enable backup/restore features |
| Variable | Default | Description |
|---|---|---|
SNIPO_S3_ENABLED |
false |
Enable S3 backup |
SNIPO_S3_ENDPOINT |
s3.amazonaws.com |
S3 endpoint URL |
SNIPO_S3_ACCESS_KEY |
- | Access key ID |
SNIPO_S3_SECRET_KEY |
- | Secret access key |
SNIPO_S3_BUCKET |
snipo-backups |
Bucket name |
SNIPO_S3_REGION |
us-east-1 |
AWS region |
SNIPO_S3_SSL |
true |
Use HTTPS |
| Variable | Default | Description |
|---|---|---|
SNIPO_LOG_LEVEL |
info |
Log level: debug, info, warn, error |
SNIPO_LOG_FORMAT |
json |
Log format: json, text |
Snipo uses SQLite with automatic migrations. The database file is created at SNIPO_DB_PATH on first run.
Migrations are embedded in the binary and run automatically on startup. Migration files are in migrations/.
sqlite3 ./data/snipo.dbThe API follows RESTful conventions. See docs/openapi.yaml for the complete specification.
API requests require one of:
- Session cookie (from web login) - full admin access
- Bearer token:
Authorization: Bearer <token> - API key header:
X-API-Key: <key>
Create API tokens via Settings → API Tokens in the web UI.
API tokens have three permission levels:
- read: Can only access GET endpoints (view snippets, tags, folders)
- write: Can create, update, and delete snippets, tags, and folders
- admin: Full access including token management, settings, and backups
API endpoints are rate-limited per token:
- Read operations: 1000 requests/hour (configurable)
- Write operations: 500 requests/hour (configurable)
- Admin operations: 100 requests/hour (configurable)
Rate limit info is included in response headers:
X-RateLimit-Limit: Maximum requests allowedX-RateLimit-Remaining: Requests remainingX-RateLimit-Reset: Unix timestamp when limit resetsRetry-After: Seconds to wait (when limit exceeded)
All API responses use standardized envelopes:
Single resource:
{
"data": {...},
"meta": {
"request_id": "uuid",
"timestamp": "2024-12-24T10:30:00Z",
"version": "1.0"
}
}List with pagination:
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"total_pages": 8,
"links": {
"self": "/api/v1/snippets?page=1",
"next": "/api/v1/snippets?page=2",
"prev": null
}
},
"meta": {...}
}# Create snippet (returns {data: {...}, meta: {...}})
curl -X POST http://localhost:8080/api/v1/snippets \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Example",
"files": [{"filename": "main.go", "content": "package main", "language": "go"}]
}'
# List snippets with pagination
curl "http://localhost:8080/api/v1/snippets?page=1&limit=20" \
-H "Authorization: Bearer TOKEN"
# Search snippets
curl "http://localhost:8080/api/v1/snippets/search?q=example" \
-H "Authorization: Bearer TOKEN"
# Export backup
curl -o backup.json "http://localhost:8080/api/v1/backup/export" \
-H "Authorization: Bearer TOKEN"
# Get API documentation
curl http://localhost:8080/api/v1/openapi.jsonNote: All responses are wrapped in envelopes. Access data via response.data instead of directly using the response body.
Releases are automated via GitHub Actions when a version tag is pushed.
git checkout main
git pull origin main
git tag v1.0.0
git push origin v1.0.0Follow Semantic Versioning:
- Major (
v2.0.0): Breaking changes (If needed) - Minor (
v1.1.0): New features, backward compatible - Patch (
v1.0.1): Bug fixes
Each release includes:
snipo_linux_amd64.tar.gz- Linux x86_64 binarysnipo_linux_arm64.tar.gz- Linux ARM64 binary- Docker images:
- Standard:
ghcr.io/mohamedelashri/snipo:v1.0.0,:v1.0,:v1,:latest - Hardened:
ghcr.io/mohamedelashri/snipo:v1.0.0-hardened,:v1.0-hardened,:v1-hardened,:hardened
- Standard:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Make changes and add tests
- Run
make testandmake lint - Commit with clear messages
- Open a pull request
- Follow standard Go conventions
- Run
gofmtbefore committing - Keep functions focused and testable
- Add comments for exported functions
| Shortcut | Action |
|---|---|
Ctrl+K / Cmd+K |
Focus search |
Ctrl+N / Cmd+N |
New snippet |
Escape |
Close editor/modal |
Snipo serves all frontend JavaScript and CSS libraries locally (no CDN) for privacy. Libraries are managed via npm but served from the internal/web/static/vendor/ directory.
- htmx - HTML-over-the-wire interactions
- Alpine.js - Reactive UI framework
- Ace Editor - Code editor with syntax highlighting
- Prism.js - Syntax highlighting for display
- Marked - Markdown parser
- Pico CSS - Minimal CSS framework
# Full one-shot setup: install + sync + verify
make vendor
# Or step by step
make vendor-install # Install npm dependencies
make vendor-sync # Copy files to internal/web/static/vendor/
make vendor-verify # Verify all expected files existmake vendor-check # Check for outdated packages
make vendor-status # Show current installed vs wanted versions
make vendor-update # Update (minor/patch only)
make vendor-update-major # Update (including major versions)
make vendor-cleanup # Remove orphaned vendor files- Dependencies are declared in
package.jsonwith semantic versioning npm installdownloads packages tonode_modules/(gitignored)scripts/sync-vendor.jscopies specific files tointernal/web/static/vendor/(committed)scripts/verify-vendor.jschecks that all expected files exist and can clean up orphans- Your app serves files from the vendor directory
- Install the package:
npm install <package-name> - Edit
scripts/sync-vendor.jsto add file mappings:
const vendorConfig = {
js: {
'newlib.min.js': 'package-name/dist/newlib.min.js',
}
};- Sync and verify:
make vendor - Update HTML templates to include the new library
Snipo supports extensive visual customization through custom CSS. See customization.md for a complete guide on:
- Overriding CSS variables for colors and spacing
- Customizing component styles (sidebar, editor, modals)
- Creating custom themes
- Best practices and examples
Users can add custom CSS through Settings → Appearance → Custom CSS.
Snipo implements two-way synchronization with GitHub Gists using a settings-based approach (no OAuth required).
Backend (Go):
internal/models/gist_sync.go- Data models for config, mappings, conflicts, logsinternal/repository/gist_sync_repo.go- Database operations for gist syncinternal/services/encryption_service.go- AES-256-GCM token encryptioninternal/services/github_client.go- HTTP client for GitHub Gist APIinternal/services/checksum.go- SHA256 checksums for change detectioninternal/services/gist_converter.go- Bidirectional snippet/gist conversioninternal/services/gist_sync_service.go- Core sync logic and conflict resolutioninternal/services/gist_sync_worker.go- Background sync workerinternal/api/handlers/gist_sync_handler.go- API endpoints
Frontend (JavaScript):
internal/web/static/js/components/snippets/gist-sync-mixin.js- UI logicinternal/web/templates/components/modals.html- Settings UI (GitHub Gist tab)
Database:
migrations/007_add_gist_sync.sql- Schema for sync tables- Tables:
gist_sync_config,snippet_gist_mappings,gist_sync_conflicts,gist_sync_log
1. Settings-Based Authentication (No OAuth):
- Users provide GitHub Personal Access Token directly
- Simpler for self-hosted deployments
- No OAuth app registration required
- Token encrypted with session secret using AES-256-GCM
2. Metadata Embedding:
- Snipo-specific metadata (favorites, folders, tags) embedded in gist description
- Format:
Title\n[snipo:{json}] - Keeps gist files clean (no separate metadata file)
- Backward compatible with old metadata file approach
3. Checksum-Based Change Detection:
- SHA256 checksums calculated for normalized snippet/gist data
- Stored in
snippet_gist_mappingstable - Enables efficient conflict detection
4. Conflict Resolution Strategies:
- Manual: User chooses which version to keep
- Snipo Wins: Always use Snipo version
- Gist Wins: Always use GitHub version
- Newest Wins: Use most recently modified version
Configuration:
GET /api/v1/gist/config- Get configuration (token masked)POST /api/v1/gist/config- Save configuration and tokenDELETE /api/v1/gist/config- Clear token and disable syncPOST /api/v1/gist/config/test- Test token validity
Sync Operations:
POST /api/v1/gist/sync/snippet/{id}- Sync specific snippetPOST /api/v1/gist/sync/all- Sync all enabled snippetsPOST /api/v1/gist/sync/enable/{id}- Enable sync for snippetPOST /api/v1/gist/sync/enable-all- Enable sync for all snippetsPOST /api/v1/gist/sync/disable/{id}- Disable sync for snippet
Mappings & Conflicts:
GET /api/v1/gist/mappings- List all mappingsDELETE /api/v1/gist/mappings/{id}- Remove mappingGET /api/v1/gist/conflicts- List unresolved conflictsPOST /api/v1/gist/conflicts/{id}/resolve- Resolve conflictGET /api/v1/gist/logs- View sync operation logs
-
Enable Sync for Snippet:
- Check if mapping exists
- If not, create gist via GitHub API
- Calculate checksums for both versions
- Store mapping with checksums
-
Detect Changes:
- Fetch current snippet and gist
- Calculate current checksums
- Compare with stored checksums
- Return: NoSync, SnipoToGist, GistToSnipo, or Conflict
-
Sync Snippet to Gist:
- Convert snippet to gist request format
- Update gist via GitHub API
- Update checksums in mapping
- Log operation
-
Sync Gist to Snippet:
- Fetch gist from GitHub
- Convert to snippet format
- Update snippet in database
- Update checksums in mapping
- Log operation
-
Handle Conflict:
- Create conflict record
- Update mapping status to "conflict"
- Wait for user resolution or apply automatic strategy
The GistSyncWorker runs in the background and:
- Checks every 1 minute if sync is needed
- Respects
sync_interval_minutesfrom config - Only syncs if
enabledandauto_sync_enabledare true - Tracks
last_full_sync_atto prevent over-syncing - Gracefully shuts down on server stop
- Tokens encrypted with
DeriveEncryptionKey(sessionSecret)using SHA256 - Encryption uses AES-256-GCM with random nonce per encryption
- Tokens never logged or exposed in API responses
- Worker checks token existence before attempting decryption
- All API endpoints require appropriate permissions (admin/write/read)