Compare commits

...

2 Commits

Author SHA1 Message Date
b60de5f97c going prod step-by-step 2025-10-15 14:16:42 +03:00
529dd0460d maybe it's about perms? 2025-10-15 13:38:41 +03:00
7 changed files with 537 additions and 5 deletions

View File

@@ -12,8 +12,8 @@ GID=1000
ADMIN_TOKEN=your-secure-admin-token-here ADMIN_TOKEN=your-secure-admin-token-here
# Optional: Service configuration (defaults shown) # Optional: Service configuration (defaults shown)
DEPLOY_ROOT=/var/www/tingz-docs DEPLOY_ROOT=/var/www/docs
RELEASE_ROOT=/var/www/tingz-deploys RELEASE_ROOT=/var/www/deploys
DB_PATH=/data/deployer.db DB_PATH=/data/deployer.db
# Optional: Upload and retention settings # Optional: Upload and retention settings

View File

@@ -7,10 +7,12 @@ services:
args: args:
UID: ${UID} UID: ${UID}
GID: ${GID} GID: ${GID}
# for the volumes below, ensure the server user has write access
# "server user" is the user that UID and GID's are passed with env variables
volumes: volumes:
- ./data:/data - ./volumes/data:/data # SQLite DB
- ./docs:/var/www/docs - ./volumes/docs:/var/www/docs # published files
- ./deploys:/var/www/deploys - ./volumes/deploys:/var/www/deploys # releases
ports: ports:
- "${HOST}:${PORT}:8080" - "${HOST}:${PORT}:8080"
user: "${UID}:${GID}" user: "${UID}:${GID}"

21
example.caddy Normal file
View File

@@ -0,0 +1,21 @@
docs.example.org {
# API requests go to Docker container
handle /api* {
reverse_proxy HOST:PORT # TODO: set to HOST and PORT from .env
}
handle {
file_server {
root $COMPOSE_DIR/volumes/docs # TODO: change $COMPOSE_DIR to the actual path
index index.html
browse off
}
}
#log {
# output_file /var/log/caddy/docs.example.org.log {
# roll_size 10MB
# roll_keep 10
# }
#}
}

View File

@@ -202,6 +202,14 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleHello(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"message": "Close the world, .txen eht nepO",
"author": "Yigid BALABAN <hey@yigid.dev>",
"authorHomepage": "https://yigid.dev/",
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) { func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -23,6 +23,7 @@ func NewRouter(cfg *config.Config, userMgr *user.Manager, deployMgr *deploy.Mana
mux.Handle("DELETE /api/v1/auth", adminAuth(http.HandlerFunc(server.handleAuthDelete))) mux.Handle("DELETE /api/v1/auth", adminAuth(http.HandlerFunc(server.handleAuthDelete)))
mux.Handle("POST /api/v1/deploy", userAuth(maxBytes(http.HandlerFunc(server.handleDeploy)))) mux.Handle("POST /api/v1/deploy", userAuth(maxBytes(http.HandlerFunc(server.handleDeploy))))
mux.HandleFunc("GET /api/v1/status", server.handleStatus) mux.HandleFunc("GET /api/v1/status", server.handleStatus)
mux.HandleFunc("GET /api/hello", server.handleHello)
return LoggingMiddleware(logger)(mux) return LoggingMiddleware(logger)(mux)
} }

444
openapi.yaml Normal file
View File

@@ -0,0 +1,444 @@
openapi: 3.0.3
info:
title: Tingz Deployer API
description: A secure, lightweight HTTP service for deploying static sites. The service receives tarball artifacts, validates and publishes them under a per-user directory structure for docs.yigid.dev. Designed for CI/CD automation with GitHub Actions, GitLab CI, etc.
version: 1.0.0
contact:
name: Yigid BALABAN
email: hey@yigid.dev
url: https://yigid.dev/
license:
name: AGPL-3.0-only
url: https://opensource.org/license/agpl-v3
servers:
- url: https://docs.yigid.dev
description: Production server
- url: http://localhost:8080
description: Development server
paths:
/api/v1/auth:
post:
tags:
- Authentication
summary: Create or reroll user token
description: Creates a new user or generates a new token for an existing user. Admin only endpoint.
operationId: createOrRerollUser
security:
- AdminAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
example:
username: alice
responses:
'200':
description: User created or token rerolled successfully
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
example:
status: ok
token: 82lz0BVOWtV4uVj9DBZBuKMeyVDwidL2
'400':
description: Invalid request body or username format
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidBody:
summary: Invalid request body
value:
status: error
error: invalid request body
invalidUsername:
summary: Invalid username format
value:
status: error
error: invalid username format
'401':
description: Unauthorized - Invalid admin token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: invalid admin token
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: failed to create user
delete:
tags:
- Authentication
summary: Delete user
description: Deletes a user and optionally their files. Admin only endpoint.
operationId: deleteUser
security:
- AdminAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteUserRequest'
examples:
persistFiles:
summary: Delete user but keep files
value:
username: alice
files: persist
deleteFiles:
summary: Delete user and all files
value:
username: alice
files: delete
responses:
'200':
description: User deleted successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
example:
status: ok
'400':
description: Invalid request body, username format, or files parameter
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidBody:
summary: Invalid request body
value:
status: error
error: invalid request body
invalidUsername:
summary: Invalid username format
value:
status: error
error: invalid username format
invalidFiles:
summary: Invalid files parameter
value:
status: error
error: files must be 'persist' or 'delete'
'401':
description: Unauthorized - Invalid admin token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: invalid admin token
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: user not found
/api/v1/deploy:
post:
tags:
- Deployment
summary: Deploy static site
description: Uploads and deploys a static site tarball. Creates a new release and atomically updates the live site via symlink swap.
operationId: deploySite
security:
- UserAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- project
- file
properties:
project:
type: string
description: Project name (must match username validation pattern)
pattern: ^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$
minLength: 1
maxLength: 64
example: myproject
file:
type: string
format: binary
description: Tarball file (.tar.gz) containing the static site
responses:
'200':
description: Deployment successful
content:
application/json:
schema:
$ref: '#/components/schemas/DeployResponse'
example:
status: ok
project: myproject
username: alice
url: https://docs.yigid.dev/alice/myproject/
release: 20251014T180806
'400':
description: Invalid request - missing file, invalid project name, or multipart form parsing error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
missingFile:
summary: Missing or invalid file
value:
status: error
error: missing or invalid file
invalidProject:
summary: Invalid project name format
value:
status: error
error: invalid project name format
parseError:
summary: Failed to parse multipart form
value:
status: error
error: failed to parse multipart form
'401':
description: Unauthorized - Invalid user token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: unauthorized
'413':
description: Request entity too large - Upload exceeds maximum size limit
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
status: error
error: request body too large
'500':
description: Internal server error during deployment
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
extractionError:
summary: Failed to extract tarball
value:
status: error
error: 'failed to extract tarball: path contains ''..'' component which is not allowed: ../../etc/passwd'
generalError:
summary: General deployment error
value:
status: error
error: deployment failed
/api/v1/status:
get:
tags:
- Health
summary: Health check
description: Returns the current status of the service
operationId: getStatus
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/StatusResponse'
example:
status: ok
/api/hello:
get:
tags:
- Info
summary: Service information
description: Returns basic information about the service and author
operationId: getHello
responses:
'200':
description: Service information
content:
application/json:
schema:
$ref: '#/components/schemas/HelloResponse'
example:
message: Close the world, .txen eht nepO
author: Yigid BALABAN <hey@yigid.dev>
authorHomepage: https://yigid.dev/
components:
securitySchemes:
AdminAuth:
type: http
scheme: bearer
description: Admin bearer token for user management operations
UserAuth:
type: http
scheme: bearer
description: User bearer token for deployment operations
schemas:
CreateUserRequest:
type: object
required:
- username
properties:
username:
type: string
description: Username for the new user (lowercase letters, numbers, hyphens, underscores only)
pattern: ^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$
minLength: 1
maxLength: 64
example: alice
DeleteUserRequest:
type: object
required:
- username
properties:
username:
type: string
description: Username of the user to delete
pattern: ^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$
minLength: 1
maxLength: 64
example: alice
files:
type: string
description: Whether to persist or delete user files
enum:
- persist
- delete
default: persist
example: persist
AuthResponse:
type: object
required:
- status
- token
properties:
status:
type: string
enum:
- ok
example: ok
token:
type: string
description: 32-character alphanumeric token for API access
minLength: 32
maxLength: 32
example: 82lz0BVOWtV4uVj9DBZBuKMeyVDwidL2
DeployResponse:
type: object
required:
- status
- project
- username
- url
- release
properties:
status:
type: string
enum:
- ok
example: ok
project:
type: string
description: Project name
example: myproject
username:
type: string
description: Username who deployed
example: alice
url:
type: string
format: uri
description: Public URL where the site is accessible
example: https://docs.yigid.dev/alice/myproject/
release:
type: string
description: 'Release ID (timestamp format: YYYYMMDDTHHMMSS)'
pattern: ^[0-9]{8}T[0-9]{6}$
example: 20251014T180806
StatusResponse:
type: object
required:
- status
properties:
status:
type: string
enum:
- ok
example: ok
HelloResponse:
type: object
required:
- message
- author
- authorHomepage
properties:
message:
type: string
description: Service message
example: Close the world, .txen eht nepO
author:
type: string
description: Author information
example: Yigid BALABAN <hey@yigid.dev>
authorHomepage:
type: string
format: uri
description: Author homepage URL
example: https://yigid.dev/
SuccessResponse:
type: object
required:
- status
properties:
status:
type: string
enum:
- ok
example: ok
ErrorResponse:
type: object
required:
- status
- error
properties:
status:
type: string
enum:
- error
example: error
error:
type: string
description: Error message describing what went wrong
example: invalid username format
tags:
- name: Authentication
description: User management operations (admin only)
- name: Deployment
description: Static site deployment operations
- name: Health
description: Service health and status endpoints
- name: Info
description: Service information endpoints

56
pre-docker-deploy.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euxo pipefail
echo "=== tingz pre-docker script ==="
echo "This script assumes you're running it on a Debian 12+ system."
echo "This script assumes you haven't created a tingz user & group yet."
echo "This script assumes you are using the default volumes directory structure."
echo
echo "Description:"
echo "This script creates a tingz user & group,"
echo "creates and sets permissions for volumes,"
echo "and creates a .env.development file."
echo
echo "1. Check dependencies"
commands=(docker adduser addgroup cut getent)
for cmd in "${commands[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: $cmd could not be found"
exit 1
fi
done
echo "2. Add tingz user & group"
addgroup --system tingz
GID=$(getent group tingz | cut -d: -f3)
adduser --ingroup tingz --system --no-create-home --uid ${GID} --shell /usr/sbin/nologin tingz
echo "3. Verify tingz group and user"
getent group tingz
getent passwd tingz
echo "4. Create and set permissions for volumes"
mkdir -p volumes/data volumes/docs volumes/deploys
chown -R tingz:tingz volumes
echo "5. Create .env.tmp file"
ADMIN_TOKEN=$(openssl rand -hex 16)
cat > .env.tmp << EOF
ADMIN_TOKEN=${ADMIN_TOKEN}
HOST=127.0.0.1
PORT=8080
UID=${GID}
GID=${GID}
DEPLOY_ROOT=/var/www/docs
RELEASE_ROOT=/var/www/deploys
DB_PATH=/data/deployer.db
MAX_UPLOAD_SIZE=104857600
EOF
echo "Please verify .env.temp and move into .env if everything is correct"
echo "=== .env.tmp ==="
cat .env.tmp
echo "=== .env ==="
echo "Done"