commit 77dfcaf64a2574bab8831c5853b83a2a81e98afb Author: admin Date: Fri Aug 14 23:35:38 2026 +0100 Initial import: KeepNote service (dcntrlized-auth consumer) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5d535 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Go build artifacts (Air runs inside the dev container as root) +src/tmp/ +src/src/ +src/logs/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ab7369e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,57 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to KeepNote (Air)", + "type": "go", + "request": "attach", + "mode": "remote", + "port": 2345, + "host": "127.0.0.1", + "debugAdapter": "legacy", + "substitutePath": [ + { + "from": "${workspaceFolder}/src", + "to": "/app" + } + ], + "cwd": "${workspaceFolder}/src", + "showLog": true, + // "trace": "verbose", + "presentation": { + "hidden": false, + "group": "KeepNote", + "order": 1 + } + }, + { + "name": "Launch browser", + "type": "editor-browser", + "request": "launch", + "url": "http://localhost:7000", + "presentation": { + "hidden": true, + "group": "", + "order": 1 + } + }, + { + "name": "Launch dev server (python)", + "type": "debugpy", + "module": "http.server", + "args": ["7000", "-d", "${workspaceFolder:KeepNote}/dev/"], + "request": "launch", + "presentation": { + "hidden": true, + "group": "", + "order": 1 + } + } + ], + "compounds": [ + { + "name": "Launch Debug Dev UI", + "configurations": ["Launch dev server (python)", "Launch browser"] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..099c0f3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,36 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start KeepNote Environment", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml up --build", + "isBackground": true, + "problemMatcher": [ + { + "pattern": { + "regexp": "^.*$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "Building", + "endsPattern": " ✔ Container" + } + } + ], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Stop KeepNote Environment", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml down", + "presentation": { + "reveal": "always", + "panel": "shared" + } + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36e2709 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# --- Build Stage --- +FROM golang:1.26 AS builder +WORKDIR /app + +# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by +# the dev Gitea Go module proxy. Override via --build-arg in production. +ARG GOPROXY_VALUE=http://192.168.122.184:3000/api/packages/Dcntrlized/go +ARG GONOSUMDB_VALUE=git.dcntrlized.org +ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct +ENV GONOSUMDB=${GONOSUMDB_VALUE} + +COPY src/go.mod src/go.sum ./ +RUN go mod download + +COPY src/ ./ + +RUN CGO_ENABLED=0 GOOS=linux go build -o server main.go + +# --- Run Stage --- +FROM alpine:latest +WORKDIR /root/ + +LABEL org.dcntrlized.service=KeepNote +LABEL org.dcntrlized.port=8080 + +COPY --from=builder /app/server . + +CMD ./server diff --git a/Dockerfile.debug b/Dockerfile.debug new file mode 100644 index 0000000..81354bc --- /dev/null +++ b/Dockerfile.debug @@ -0,0 +1,65 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-3-create-the-development-dockerfile + +# Dockerfile (Development) +# This Dockerfile is optimized for development with hot reloading +# It includes Air and mounts your source code as a volume + +# Use the official Go image as our base +# Alpine variant is smaller but we use the full image for better compatibility +FROM golang:1.26-bookworm + +# Set the working directory inside the container +# All subsequent commands will run from this directory +WORKDIR /app + +# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by +# the dev Gitea Go module proxy. Override via --build-arg in production. +ARG GOPROXY_VALUE=http://192.168.122.184:3000/api/packages/Dcntrlized/go +ARG GONOSUMDB_VALUE=git.dcntrlized.org +ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct +ENV GONOSUMDB=${GONOSUMDB_VALUE} + +# Install Air for hot reloading +# We install it globally so it's available in the PATH +RUN go install github.com/air-verse/air@latest + +# Install additional development tools (optional but useful) +# - delve: Go debugger for debugging inside containers +# - golangci-lint: Linter for code quality checks +RUN go install github.com/go-delve/delve/cmd/dlv@latest && \ + curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.2 + +# Copy go.mod and go.sum first for better layer caching +# This layer only rebuilds when dependencies change +COPY src/go.mod src/go.sum* ./ + +# Download all dependencies +# This is cached separately from source code changes +RUN go mod download && go mod verify + +# Copy the Air configuration file +# This tells Air how to watch and rebuild our application +COPY ./src/.air.toml ./ + +# Note: We do NOT copy source code here! +# Source code is mounted as a volume in Docker Compose +# This enables hot reloading without rebuilding the image + +# Expose the application port +# Make sure this matches your application's listening port +EXPOSE 8080 + +# Expose delve debugger port (optional, for debugging) +EXPOSE 2345 + +# Set environment variables for development +ENV GO111MODULE=on + +# Default command: run Air for hot reloading +# Air will watch for changes and rebuild automatically +CMD ["air", "-c", ".air.toml"] + +## + +LABEL org.dcntrlized.service=KeepNote +LABEL org.dcntrlized.port=8080 \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f4e6f79 --- /dev/null +++ b/build.sh @@ -0,0 +1,12 @@ +#!/bin/env bash +set -euo pipefail + +IMAGE_NAME="keepnote" +DOCKERFILE="Dockerfile" + +if [[ "${1:-}" == "--debug" ]]; then + DOCKERFILE="Dockerfile.debug" + IMAGE_NAME="keepnote:debug" +fi + +docker build --network=host -t "$IMAGE_NAME" -f "$DOCKERFILE" . diff --git a/database/migrations/001_init_schema.sql b/database/migrations/001_init_schema.sql new file mode 100644 index 0000000..713731e --- /dev/null +++ b/database/migrations/001_init_schema.sql @@ -0,0 +1,76 @@ +\c dcntrlized; + +CREATE SCHEMA IF NOT EXISTS keepnote; + +-- ============================================================================ +-- Auth (D3/D5, planning/platform/shared-auth/README.md): identity moved to +-- the shared dcntrlized-auth service. The per-service `users` table is +-- DISABLED below; data tables keep a plain-UUID user_id (no FK) that points +-- at auth.users(id). Existing dev rows are re-pointed by 002_shared_auth.sql. +-- ============================================================================ +-- CREATE TABLE IF NOT EXISTS keepnote.users ( +-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- username VARCHAR(255) NOT NULL UNIQUE, +-- password_hash VARCHAR(255) NOT NULL, +-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +-- ); + +CREATE TABLE IF NOT EXISTS keepnote.notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP + -- user_id is a plain UUID (platform account id, auth.users) -- no FK (D5). + -- FOREIGN KEY (user_id) REFERENCES keepnote.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS keepnote.tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS keepnote.note_tags ( + note_id UUID NOT NULL, + tag_id UUID NOT NULL, + PRIMARY KEY (note_id, tag_id), + FOREIGN KEY (note_id) REFERENCES keepnote.notes(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES keepnote.tags(id) ON DELETE CASCADE +); + +-- Added IF NOT EXISTS to indexes +CREATE INDEX IF NOT EXISTS idx_user_id ON keepnote.notes(user_id); +CREATE INDEX IF NOT EXISTS idx_note_id ON keepnote.note_tags(note_id); +CREATE INDEX IF NOT EXISTS idx_tag_id ON keepnote.note_tags(tag_id); + +-- Placed the function inside the keepnote schema +CREATE OR REPLACE FUNCTION keepnote.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.modified = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Drop the trigger if it exists, then recreate it +DROP TRIGGER IF EXISTS update_notes_modtime ON keepnote.notes; +CREATE TRIGGER update_notes_modtime + BEFORE UPDATE ON keepnote.notes + FOR EACH ROW + EXECUTE FUNCTION keepnote.update_updated_at_column(); + +-- Idempotent User Creation using a DO block +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'keepnote_user') THEN + CREATE USER keepnote_user WITH PASSWORD 'keepnote'; + END IF; +END +$$; + +-- Grants are naturally idempotent (they won't error if re-run) +GRANT CONNECT ON DATABASE dcntrlized TO keepnote_user; +GRANT USAGE ON SCHEMA keepnote TO keepnote_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA keepnote TO keepnote_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA keepnote GRANT ALL ON TABLES TO keepnote_user; \ No newline at end of file diff --git a/database/migrations/002_shared_auth.sql b/database/migrations/002_shared_auth.sql new file mode 100644 index 0000000..894225f --- /dev/null +++ b/database/migrations/002_shared_auth.sql @@ -0,0 +1,19 @@ +\c dcntrlized; + +-- Plain-UUID user_id: drop the legacy FK (D5). +ALTER TABLE IF EXISTS keepnote.notes DROP CONSTRAINT IF EXISTS notes_user_id_fkey; + +-- Re-point existing rows to auth.users by username, then drop the old table. +-- No-op on fresh DBs (the old users table never existed). +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_catalog.pg_tables WHERE schemaname='keepnote' AND tablename='users') + AND EXISTS (SELECT FROM pg_catalog.pg_tables WHERE schemaname='auth' AND tablename='users') THEN + UPDATE keepnote.notes n + SET user_id = a.id + FROM keepnote.users old + JOIN auth.users a ON a.username = old.username + WHERE n.user_id = old.id; + DROP TABLE keepnote.users; + END IF; +END $$; diff --git a/database/requirements.txt b/database/requirements.txt new file mode 100644 index 0000000..607a9b1 --- /dev/null +++ b/database/requirements.txt @@ -0,0 +1,2 @@ +psycopg2 +bcrypt \ No newline at end of file diff --git a/database/seeds/001_seed_users.py b/database/seeds/001_seed_users.py new file mode 100644 index 0000000..c32b171 --- /dev/null +++ b/database/seeds/001_seed_users.py @@ -0,0 +1,11 @@ +""" +User seeding moved to the shared dcntrlized-auth service (D3/D8, +planning/platform/shared-auth/README.md). Accounts are seeded by +dcntrlized-auth/database/seeds/001_seed_users.py: + admin / admin@test.dcntrlized.org / admin_password_123 + johndoe / johndoe@test.dcntrlized.org / john_password_456 + janedoe / janedoe@test.dcntrlized.org / jane_password_789 +Kept as a stub for historical reference; intentionally does nothing. +""" +if __name__ == "__main__": + print("User seeding moved to dcntrlized-auth. Nothing to do here.") diff --git a/database/setup.py b/database/setup.py new file mode 100644 index 0000000..eebaf34 --- /dev/null +++ b/database/setup.py @@ -0,0 +1,85 @@ +import os +import sys +import argparse +import subprocess +from pathlib import Path + +# Hardcoded directories +MIGRATIONS_DIR = "migrations" +SEEDS_DIR = "seeds" + +def main(): + # Connection settings are env-overridable so the script can target either a + # local Postgres or the shared dcntrlized-db container (PGHOST=dcntrlized-db). + user = os.environ.get("PGUSER", "postgres") + password = os.environ.get("PGPASSWORD", "") + dbname = os.environ.get("PGDATABASE", "postgres") + host = os.environ.get("PGHOST", "localhost") + port = os.environ.get("PGPORT", "5432") + + print("==========================================") + print("Starting Database Setup & Seeding") + print(f"Host: {host}:{port} | User: {user} | DB: {dbname}") + print("==========================================\n") + + os.environ["PGUSER"] = user + os.environ["PGPASSWORD"] = password + os.environ["PGDATABASE"] = dbname + os.environ["PGHOST"] = host + os.environ["PGPORT"] = port + + # 1. Run SQL Migrations + print("--- Running SQL Migrations ---") + migrations_path = Path(MIGRATIONS_DIR) + + if not migrations_path.is_dir(): + print(f"Warning: Migrations directory '{MIGRATIONS_DIR}' not found.") + else: + # Grab all .sql files and sort them (e.g., 001_..., 002_...) + sql_files = sorted(migrations_path.glob("*.sql")) + if not sql_files: + print(f"No SQL migration files found in '{MIGRATIONS_DIR}/'.") + + for sql_file in sql_files: + print(f"Applying: {sql_file.name}") + # Run psql with ON_ERROR_STOP=1 to fail fast if SQL throws an error + result = subprocess.run( + ["psql", "-v", "ON_ERROR_STOP=1", "-f", str(sql_file)], + check=False + ) + if result.returncode != 0: + print(f"\n❌ Migration failed on {sql_file.name}") + sys.exit(result.returncode) + + # 2. Run Python Seed Scripts + print("\n--- Running Python Seed Scripts ---") + + os.environ["PGDATABASE"] = "dcntrlized" + + seeds_path = Path(SEEDS_DIR) + + if not seeds_path.is_dir(): + print(f"Warning: Seeds directory '{SEEDS_DIR}' not found.") + else: + # Grab all .py files and sort them + py_files = sorted(seeds_path.glob("*.py")) + if not py_files: + print(f"No Python seed files found in '{SEEDS_DIR}/'.") + + for py_file in py_files: + print(f"Executing: {py_file.name}") + # Run the seed script using the same Python interpreter running this script + result = subprocess.run( + [sys.executable, str(py_file)], + check=False + ) + if result.returncode != 0: + print(f"\n❌ Seeding failed on {py_file.name}") + sys.exit(result.returncode) + + print("\n==========================================") + print("✅ All migrations and seeds completed successfully!") + print("==========================================") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/dev/auth.html b/dev/auth.html new file mode 100644 index 0000000..07a1d49 --- /dev/null +++ b/dev/auth.html @@ -0,0 +1,75 @@ + + + + + + Log In / Register + + + + + +

KeepNote

+
+
+

Log in

+ + + +
+
+

Register

+ + + + +
+ + + diff --git a/dev/cookies.js b/dev/cookies.js new file mode 100644 index 0000000..902cb09 --- /dev/null +++ b/dev/cookies.js @@ -0,0 +1,21 @@ +function cookieBaker(key, value, expiry, path, secure, httpOnly, domain, sameSite) { + // Fuck secure parsing of the values real devs create cookies with unparsed values + document.cookie = `${key}=${value};${expiry ? `Expires=${expiry};` : ''} ${path ? `Path=${path};` : ''} Domain=${domain ? `${domain};` : ''} ${secure ? 'Secure;' : ''} ${httpOnly ? 'HttpOnly;' : ''} ${sameSite ? `SameSite=${sameSite};` : ''}` +} + +function cookieEater(key) { + // https://www.w3schools.com/js/js_cookies.asp + let name = key + "="; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} \ No newline at end of file diff --git a/dev/index.html b/dev/index.html new file mode 100644 index 0000000..4c379e0 --- /dev/null +++ b/dev/index.html @@ -0,0 +1,23 @@ + + + + + + Index + + + + + + + diff --git a/dev/jwt.js b/dev/jwt.js new file mode 100644 index 0000000..75cde27 --- /dev/null +++ b/dev/jwt.js @@ -0,0 +1,23 @@ +function jwtDecode(jwt) { + // Source: https://inventivehq.com/blog/how-to-decode-a-jwt + + const parts = jwt.split('.'); + const header = parts[0]; + const payload = parts[1]; + const signature = parts[2]; + // Base64URL to Base64 + function base64UrlToBase64(str) { + return str.replace(/-/g, '+').replace(/_/g, '/'); + } + + // Decode + const decodedHeader = JSON.parse(atob(base64UrlToBase64(header))); + const decodedPayload = JSON.parse(atob(base64UrlToBase64(payload))); + + return { + raw: jwt, + header: decodedHeader, + payload: decodedPayload, + signature: signature + } +} \ No newline at end of file diff --git a/dev/main.css b/dev/main.css new file mode 100644 index 0000000..5f494a9 --- /dev/null +++ b/dev/main.css @@ -0,0 +1,74 @@ +* { + box-sizing: border-box; +} + +body { + font-family: system-ui, sans-serif; + margin: 2rem auto; + max-width: 40rem; + padding: 0 1rem; + color: #222; +} + +h1 { + margin-bottom: 0.5rem; +} + +form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 1rem 0; + max-width: 20rem; +} + +form h2 { + margin: 0.5rem 0 0; +} + +input { + padding: 0.4rem; +} + +button { + padding: 0.4rem; + cursor: pointer; +} + +#toolbar { + margin-bottom: 1rem; +} + +#error { + color: #b00020; + min-height: 1.2em; + white-space: pre-wrap; +} + +#notes li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid #ddd; +} + +.tag { + background: #eee; + border-radius: 0.75rem; + padding: 0.1rem 0.5rem; + font-size: 0.8em; +} + +.edit-note { + max-width: 100%; + width: 100%; + flex-direction: row; + flex-wrap: wrap; +} + +.edit-note input { + flex: 1; + min-width: 8rem; +} diff --git a/dev/notes.html b/dev/notes.html new file mode 100644 index 0000000..b318016 --- /dev/null +++ b/dev/notes.html @@ -0,0 +1,173 @@ + + + + + + Notes + + + + + +

KeepNote

+
+ +
+
+ + + +
+
+ + + + diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml new file mode 100644 index 0000000..eca67b6 --- /dev/null +++ b/docker-compose.debug.yaml @@ -0,0 +1,101 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-4-set-up-docker-compose + +# docker-compose.yml +# Development environment with hot reloading + +services: + # Main Go application with Air hot reloading + keepnote-debug: + extra_hosts: + - "host.docker.internal:host-gateway" + + image: keepnote-debug + container_name: keepnote-debug + + # Build from our development Dockerfile + build: + context: . + dockerfile: Dockerfile.debug + network: host + + + # Expose ports: host:container + ports: + - "8081:8080" # Application port + - "2345:2345" # Delve debugger port (optional) + # - "8081:8081" # HTTP Dev interface + + + # Volume mounts - THE KEY TO HOT RELOADING + # Mount source code as a volume so changes are reflected immediately + volumes: + - ./src:/app + - go-mod-cache:/go/pkg/mod + - go-build-cache:/root/.cache/go-build + - /app/src/tmp # Exclude Air's tmp dir from syncing + + working_dir: /app/ + + # Environment variables for the application + environment: + - DEV_MODE=true + # Application configuration + # - PORT=8080 + # - ENVIRONMENT=development + # - LOG_LEVEL=debug + + # Database connection (shared database on the dcntrlized network) + - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized + + # Go specific settings + - GO111MODULE=on + - GOPROXY=http://192.168.122.184:3000/api/packages/Dcntrlized/go,https://proxy.golang.org,direct + - GONOSUMDB=git.dcntrlized.org + + depends_on: + - migrate + + # Restart policy for development + restart: unless-stopped + + # Network for service communication + networks: + - dcntrlized_net + + cap_add: + - SYS_PTRACE + security_opt: + - apparmor:unconfined + + migrate: + image: postgres:16-alpine + container_name: keepnote-migrate + labels: + org.dcntrlized.oneoff: "true" + networks: + - dcntrlized_net + volumes: + - ./database/migrations:/migrations:ro + environment: + - PGHOST=dcntrlized-db + - PGPORT=5432 + - PGUSER=postgres + - PGPASSWORD= + - PGDATABASE=dcntrlized + command: > + sh -c 'until pg_isready -h dcntrlized-db -U postgres -d dcntrlized; do sleep 2; done; + for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -d dcntrlized -f "$$f" || exit 1; done' + +# Named volumes for data persistence and caching +volumes: + # Go module cache - shared across rebuilds + go-mod-cache: + driver: local + + # Go build cache - speeds up compilation + go-build-cache: + driver: local + +networks: + dcntrlized_net: + external: true diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..90cc036 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,42 @@ +services: + keepnote-service: + extra_hosts: + - "host.docker.internal:host-gateway" + image: keepnote + container_name: keepnote + networks: + - dcntrlized_net + ports: + - "8081:8080" + - "9090:90" + environment: + - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized + - AUTH_SERVICE_URL=http://dcntrlized-auth:8080 + depends_on: + - migrate + build: + context: . + dockerfile: Dockerfile + + migrate: + image: postgres:16-alpine + container_name: keepnote-migrate + labels: + org.dcntrlized.oneoff: "true" + networks: + - dcntrlized_net + volumes: + - ./database/migrations:/migrations:ro + environment: + - PGHOST=dcntrlized-db + - PGPORT=5432 + - PGUSER=postgres + - PGPASSWORD= + - PGDATABASE=dcntrlized + command: > + sh -c 'until pg_isready -h dcntrlized-db -U postgres -d dcntrlized; do sleep 2; done; + for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -d dcntrlized -f "$$f" || exit 1; done' + +networks: + dcntrlized_net: + external: true diff --git a/src/.air.toml b/src/.air.toml new file mode 100644 index 0000000..859c04f --- /dev/null +++ b/src/.air.toml @@ -0,0 +1,144 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-2-install-and-configure-air + +# .air.toml - Air Configuration for Hot Reloading + +# This file configures how Air watches and rebuilds your Go application + +# Root directory for watching - use "." for current directory +root = "." + +# Temporary directory where Air stores build artifacts +# This directory is created automatically and should be gitignored +tmp_dir = "tmp" + +[build] +# Command to build your application +# Add any build flags you need here (like -race for race detection) +cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ." + +# Tell Air to launch the compiled binary inside Delve using the legacy API +full_bin = "dlv exec --accept-multiclient --headless --continue --listen=:2345 --api-version=2 ./tmp/main" + +# Path to the built binary that Air will execute +# bin = "./tmp/main" + +# Entrypoint binary relative to root +# Air recommends this over relying on bin alone +# entrypoint = ["./tmp/main"] + +# Arguments to pass to your application when running +# Useful for passing flags or configuration +args_bin = [] + +# Pre-build command - runs before each build +# Useful for code generation, proto compilation, etc. +pre_cmd = [] + +# Post-build command - runs after each successful build +# Useful for running migrations or other setup tasks +post_cmd = [] + +# Enable polling mode for file watching (useful for Docker on some OSes) +# Set to true if you're on Windows or using certain Docker setups +poll = false + +# Polling interval in milliseconds (only used if poll = true) +poll_interval = 500 + +# Build delay after file change is detected (in milliseconds) +# Helps batch multiple rapid changes into one rebuild +delay = 1000 + +# Stop running the old binary before building the new one +# Recommended to avoid port conflicts +stop_on_error = true + +# Send interrupt signal before killing process +# Allows graceful shutdown of your application +send_interrupt = true + +# Delay after interrupt before force killing +kill_delay = "500ms" + +# Rerun binary on build error - useful for debugging +rerun = false + +# Delay before rerunning (in milliseconds) +rerun_delay = 500 + +# Build error log file, placed inside tmp_dir +log = "build-errors.log" + +# Directories to watch for changes +# Air watches the whole root unless you restrict it here +include_dir = [] + +# Directories to exclude from watching +# These directories will be ignored when detecting file changes +exclude_dir = [ + "tmp", + "vendor", + "node_modules", + ".git", + "testdata", + "docs", + "scripts", +] + +# File patterns to exclude from watching +# Useful for ignoring generated files, tests, or documentation +exclude_regex = [ + "_test\\.go$", + "_mock\\.go$", + "\\.pb\\.go$", + "swagger\\.json$", + "\\.md$", +] + +# File extensions to watch - only these file types trigger rebuilds +# Add more extensions if you have other file types that should trigger rebuilds +include_ext = ["go", "tpl", "tmpl", "html", "env"] + +# Specific files to exclude from watching +exclude_file = [ + "go.sum", + ".air.toml", +] + +# Specific files to watch (in addition to extension-based matching) +include_file = [] + +# Exclude unchanged files from triggering rebuilds +exclude_unchanged = false + +[color] +# Customize output colors for better readability in terminal +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +[log] +# Log output settings +# Time format for logs (Go time format) +time = true + +# Only show main binary output (hide Air's internal logs) +main_only = false + +[misc] +# Clean temporary directory on exit +clean_on_exit = true + +[screen] +# Clear screen on rebuild for cleaner output +clear_on_rebuild = true + +# Keep scroll position (set to false for always scrolling to top) +keep_scroll = true + +[proxy] +# Proxy configuration (optional, for advanced setups) +enabled = false +proxy_port = 8090 +app_port = 8080 \ No newline at end of file diff --git a/src/actions/auth.go b/src/actions/auth.go new file mode 100644 index 0000000..61dff05 --- /dev/null +++ b/src/actions/auth.go @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "net/http" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +// Login proxies the Maestro-mode action to /auth/login. The {payload} envelope +// was already unwrapped by the handler; the inner body is forwarded raw and +// the auth response is relayed unwrapped. +func Login(w http.ResponseWriter, r *http.Request, payload interface{}) { + Debug("login", "POST", "proxying to auth service") + authproxy.ProxyPayload(w, r, "login", payload) +} + +// Register proxies the Maestro-mode action to /auth/register. +func Register(w http.ResponseWriter, r *http.Request, payload interface{}) { + Debug("register", "POST", "proxying to auth service") + authproxy.ProxyPayload(w, r, "register", payload) +} + +// Logout proxies the GET action variant to /auth/logout (forwarded bodyless; +// the auth service keeps GET a no-op). +func Logout(w http.ResponseWriter, r *http.Request, payload interface{}) { + Debug("logout", "GET", "proxying to auth service") + authproxy.ProxyEmpty(w, r, "logout") +} + +// Refresh proxies the Maestro-mode refresh action (M5 auto-refresh path). +func Refresh(w http.ResponseWriter, r *http.Request, payload interface{}) { + Debug("refresh", "POST", "proxying to auth service") + authproxy.ProxyPayload(w, r, "refresh", payload) +} + +// Delete proxies the Maestro-mode delete action to /auth/delete. The payload +// carries the password only; the X-Access-Token header is forwarded unchanged. +func Delete(w http.ResponseWriter, r *http.Request, payload interface{}) { + Debug("delete", "POST", "proxying to auth service") + authproxy.ProxyPayload(w, r, "delete", payload) +} diff --git a/src/actions/delete.go b/src/actions/delete.go new file mode 100644 index 0000000..2a611b8 --- /dev/null +++ b/src/actions/delete.go @@ -0,0 +1,53 @@ +package actions + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/db" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" +) + +type deleteNoteRequest struct { + ID uuid.UUID `json:"id"` +} + +func DeleteNote(w http.ResponseWriter, r *http.Request, payload interface{}) error { + Debug("deleteNote", "DELETE", "called") + principal, err := authclient.Authorize(r) + if err != nil { + Warn("deleteNote", "DELETE", "authorization failed: %v", err) + unauthorized(w, err) + return err + } + + var req deleteNoteRequest + payloadBytes, err := json.Marshal(payload) + if err != nil { + Warn("deleteNote", "DELETE", "invalid payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + if err := json.Unmarshal(payloadBytes, &req); err != nil { + Warn("deleteNote", "DELETE", "invalid payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + if req.ID == uuid.Nil { + Warn("deleteNote", "DELETE", "missing note id") + http.Error(w, "missing note id", http.StatusBadRequest) + return nil + } + + Debug("deleteNote", "DELETE", "deleting note %s for user %s", req.ID, principal.ID) + if err := db.DeleteNote(req.ID, principal.ID); err != nil { + Error("deleteNote", "DELETE", "failed to delete note: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + Debug("deleteNote", "DELETE", "note %s deleted", req.ID) + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/src/actions/errors.go b/src/actions/errors.go new file mode 100644 index 0000000..fca9d7e --- /dev/null +++ b/src/actions/errors.go @@ -0,0 +1,55 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "encoding/json" + "errors" + "net/http" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" +) + +// unauthorized writes the structured 401 body mapping authclient errors to +// the M1 vocabulary (ERR_AUTH_FAILED / ERR_TOKEN_EXPIRED / ERR_TOKEN_INVALID). +func unauthorized(w http.ResponseWriter, err error) { + code, message := "ERR_AUTH_FAILED", "unauthorized" + switch { + case errors.Is(err, authclient.ErrTokenExpired): + code, message = "ERR_TOKEN_EXPIRED", "access token expired" + case errors.Is(err, authclient.ErrTokenInvalid): + code, message = "ERR_TOKEN_INVALID", "invalid access token" + } + writeStructuredError(w, http.StatusUnauthorized, code, message, "") +} + +// writeStructuredError emits {"error":{"code","message","details"}} — the same +// body shape the auth service returns (export theme M2.3). +func writeStructuredError(w http.ResponseWriter, status int, code, message, details string) { + body := struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + } `json:"error"` + }{} + body.Error.Code = code + body.Error.Message = message + body.Error.Details = details + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/src/actions/get.go b/src/actions/get.go new file mode 100644 index 0000000..5c3aedd --- /dev/null +++ b/src/actions/get.go @@ -0,0 +1,67 @@ +package actions + +import ( + "encoding/json" + "errors" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/db" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func GetNotes(w http.ResponseWriter, r *http.Request) { + Debug("getNotes", "GET", "called") + principal, err := authclient.Authorize(r) + if err != nil { + Warn("getNotes", "GET", "authorization failed: %v", err) + unauthorized(w, err) + return + } + + // Proceed to fetch and return notes for the authenticated user. + Debug("getNotes", "GET", "fetching notes for user %s", principal.ID) + notes, err := db.QueryNotes(principal.ID) + if err != nil { + Error("getNotes", "GET", "failed to query notes: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(notes) +} + +func GetNote(w http.ResponseWriter, r *http.Request) { + Debug("getNote", "GET", "called") + principal, err := authclient.Authorize(r) + if err != nil { + Warn("getNote", "GET", "authorization failed: %v", err) + unauthorized(w, err) + return + } + + id, err := uuid.Parse(r.URL.Query().Get("id")) + if err != nil { + Warn("getNote", "GET", "missing or invalid note id: %v", err) + http.Error(w, "missing or invalid note id", http.StatusBadRequest) + return + } + + Debug("getNote", "GET", "fetching note %s for user %s", id, principal.ID) + note, err := db.GetNote(id, principal.ID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + Warn("getNote", "GET", "note %s not found for user %s", id, principal.ID) + http.Error(w, "note not found", http.StatusNotFound) + return + } + Error("getNote", "GET", "failed to fetch note: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} diff --git a/src/actions/logging.go b/src/actions/logging.go new file mode 100644 index 0000000..13f1b1f --- /dev/null +++ b/src/actions/logging.go @@ -0,0 +1,34 @@ +package actions + +import ( + "fmt" + + "git.dcntrlized.org/Dcntrlized/KeepNote/logger" +) + +func init() { + for _, name := range []string{ + "git.dcntrlized.org/Dcntrlized/KeepNote/actions.Debug", + "git.dcntrlized.org/Dcntrlized/KeepNote/actions.Warn", + "git.dcntrlized.org/Dcntrlized/KeepNote/actions.Error", + "git.dcntrlized.org/Dcntrlized/KeepNote/actions.logAction", + } { + logger.RegisterLogHelper(name) + } +} + +func Debug(action, verb, format string, v ...any) { + logAction(logger.Debug, action, verb, format, v...) +} + +func Warn(action, verb, format string, v ...any) { + logAction(logger.Warn, action, verb, format, v...) +} + +func Error(action, verb, format string, v ...any) { + logAction(logger.Error, action, verb, format, v...) +} + +func logAction(logf func(format string, v ...any), action, verb, format string, v ...any) { + logf("[action=%s verb=%s] %s", action, verb, fmt.Sprintf(format, v...)) +} diff --git a/src/actions/logs/debug.log b/src/actions/logs/debug.log new file mode 100644 index 0000000..98f035e --- /dev/null +++ b/src/actions/logs/debug.log @@ -0,0 +1,3 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:57+01:00] diff --git a/src/actions/logs/error.log b/src/actions/logs/error.log new file mode 100644 index 0000000..83bec00 --- /dev/null +++ b/src/actions/logs/error.log @@ -0,0 +1,4 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:57+01:00] +ERROR: 2026/08/09 16:55:57 actions_test.go:6: [action=getNotes verb=GET] boom diff --git a/src/actions/logs/info.log b/src/actions/logs/info.log new file mode 100644 index 0000000..98f035e --- /dev/null +++ b/src/actions/logs/info.log @@ -0,0 +1,3 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:57+01:00] diff --git a/src/actions/logs/warn.log b/src/actions/logs/warn.log new file mode 100644 index 0000000..bfb9fdb --- /dev/null +++ b/src/actions/logs/warn.log @@ -0,0 +1,5 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:57+01:00] +WARN: 2026/08/09 16:55:57 JWT_ACCESS_SECRET is not set +WARN: 2026/08/09 16:55:57 JWT_REFRESH_SECRET is not set diff --git a/src/actions/post.go b/src/actions/post.go new file mode 100644 index 0000000..d378d0e --- /dev/null +++ b/src/actions/post.go @@ -0,0 +1,52 @@ +package actions + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/db" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" +) + +func WriteNote(w http.ResponseWriter, r *http.Request, payload interface{}) (*db.Note, error) { + Debug("writeNote", "POST", "called with payload %v", payload) + principal, err := authclient.Authorize(r) + if err != nil { + Warn("writeNote", "POST", "authorization failed: %v", err) + unauthorized(w, err) + return nil, err + } + + note, err := parseNoteFromPayload(payload) + if err != nil { + Warn("writeNote", "POST", "invalid payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return nil, err + } + + Debug("writeNote", "POST", "persisting note for user %s", principal.ID) + note, err = db.WriteNote(¬e, principal.ID) + if err != nil { + Error("writeNote", "POST", "failed to persist note: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil, err + } + + Debug("writeNote", "POST", "note %s created", note.ID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(note) + + return ¬e, nil +} + +func parseNoteFromPayload(payload interface{}) (db.Note, error) { + var note db.Note + payloadBytes, err := json.Marshal(payload) + if err != nil { + return db.Note{}, err + } + if err := json.Unmarshal(payloadBytes, ¬e); err != nil { + return db.Note{}, err + } + return note, nil +} diff --git a/src/actions/update.go b/src/actions/update.go new file mode 100644 index 0000000..1c2bf82 --- /dev/null +++ b/src/actions/update.go @@ -0,0 +1,77 @@ +package actions + +import ( + "encoding/json" + "errors" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/db" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type updateNoteRequest struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags"` +} + +func UpdateNote(w http.ResponseWriter, r *http.Request, payload interface{}) error { + Debug("updateNote", "PUT", "called") + principal, err := authclient.Authorize(r) + if err != nil { + Warn("updateNote", "PUT", "authorization failed: %v", err) + unauthorized(w, err) + return err + } + + var req updateNoteRequest + payloadBytes, err := json.Marshal(payload) + if err != nil { + Warn("updateNote", "PUT", "invalid payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + if err := json.Unmarshal(payloadBytes, &req); err != nil { + Warn("updateNote", "PUT", "invalid payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + if req.ID == uuid.Nil { + Warn("updateNote", "PUT", "missing note id") + http.Error(w, "missing note id", http.StatusBadRequest) + return nil + } + + Debug("updateNote", "PUT", "updating note %s for user %s", req.ID, principal.ID) + if _, err := db.UpdateNote(req.ID, principal.ID, req.Title, req.Content); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + Warn("updateNote", "PUT", "note %s not found for user %s", req.ID, principal.ID) + http.Error(w, "note not found", http.StatusNotFound) + return nil + } + Error("updateNote", "PUT", "failed to update note: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + if err := db.ReplaceNoteTags(req.ID, req.Tags); err != nil { + Error("updateNote", "PUT", "failed to set tags: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + note, err := db.GetNote(req.ID, principal.ID) + if err != nil { + Error("updateNote", "PUT", "failed to reload note: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + Debug("updateNote", "PUT", "note %s updated", req.ID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(note) + return nil +} diff --git a/src/db/db.go b/src/db/db.go new file mode 100644 index 0000000..4f1741f --- /dev/null +++ b/src/db/db.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "log" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +var dbInstance *DB + +// databaseURL returns the DATABASE_URL from the environment, defaulting to the +// shared database as reachable by DNS on the dcntrlized network. Host-side +// development overrides it (e.g. postgres://postgres:@localhost:5432/dcntrlized +// when the database is exposed on the host). +func databaseURL() string { + if url := os.Getenv("DATABASE_URL"); url != "" { + return url + } + return "postgres://postgres:@dcntrlized-db:5432/dcntrlized" +} + +func NewDB() *DB { + if dbInstance == nil { + url := databaseURL() + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + log.Fatalf("Unable to create database pool: %v", err) + } + + // Tolerate startup ordering: the shared database container (or the + // migrate service applying schema) may not be ready when this service + // boots, so ping until it is reachable. + pingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for { + if err := pool.Ping(pingCtx); err == nil { + break + } + select { + case <-pingCtx.Done(): + pool.Close() + log.Fatalf("Database %s unreachable within 30s: %v", url, err) + case <-time.After(500 * time.Millisecond): + } + } + + dbInstance = &DB{Pool: pool} + } + return dbInstance +} + +func (db *DB) Close() { + db.Pool.Close() +} diff --git a/src/db/notes.go b/src/db/notes.go new file mode 100644 index 0000000..5bbeaa9 --- /dev/null +++ b/src/db/notes.go @@ -0,0 +1,170 @@ +package db + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" +) + +type Note struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags"` + Modified time.Time `json:"modified"` + Created time.Time `json:"created"` +} + +func GetNote(noteID, userID uuid.UUID) (*Note, error) { + db := NewDB() + + var note Note + err := db.Pool.QueryRow(context.Background(), + `SELECT n.id, n.title, n.content, n.modified, n.created, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags + FROM keepnote.notes n + LEFT JOIN keepnote.note_tags nt ON nt.note_id = n.id + LEFT JOIN keepnote.tags t ON t.id = nt.tag_id + WHERE n.id = $1 AND n.user_id = $2 + GROUP BY n.id, n.title, n.content, n.modified, n.created`, + noteID, userID, + ).Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.Modified, ¬e.Created, ¬e.Tags) + if err != nil { + return nil, err + } + + return ¬e, nil +} + +func QueryNotes(userID uuid.UUID) ([]Note, error) { + db := NewDB() + + rows, err := db.Pool.Query( + context.Background(), + `SELECT n.id, n.title, n.content, n.modified, n.created, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags + FROM keepnote.notes n + LEFT JOIN keepnote.note_tags nt ON nt.note_id = n.id + LEFT JOIN keepnote.tags t ON t.id = nt.tag_id + WHERE n.user_id = $1 + GROUP BY n.id, n.title, n.content, n.modified, n.created + ORDER BY n.modified DESC`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + notes := []Note{} + for rows.Next() { + var note Note + if err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.Modified, ¬e.Created, ¬e.Tags); err != nil { + return nil, err + } + notes = append(notes, note) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return notes, nil +} + +func UpdateNote(noteID, userID uuid.UUID, title, content string) (*Note, error) { + db := NewDB() + + var note Note + err := db.Pool.QueryRow( + context.Background(), + `UPDATE keepnote.notes + SET title=$1, content=$2, modified=NOW() + WHERE id=$3 AND user_id=$4 + RETURNING id, title, content, modified, created`, + title, content, noteID, userID, + ).Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.Modified, ¬e.Created) + if err != nil { + return nil, err + } + + return ¬e, nil +} + +func ReplaceNoteTags(noteID uuid.UUID, tags []string) error { + db := NewDB() + ctx := context.Background() + + tx, err := db.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, "DELETE FROM keepnote.note_tags WHERE note_id=$1", noteID); err != nil { + return err + } + + for _, name := range tags { + name = strings.TrimSpace(name) + if name == "" { + continue + } + var tagID uuid.UUID + err := tx.QueryRow(ctx, + `INSERT INTO keepnote.tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + name, + ).Scan(&tagID) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, + `INSERT INTO keepnote.note_tags (note_id, tag_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + noteID, tagID, + ); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +func DeleteNote(noteID, userID uuid.UUID) error { + db := NewDB() + + _, err := db.Pool.Exec( + context.Background(), + "DELETE FROM keepnote.notes WHERE id=$1 AND user_id=$2", + noteID, userID, + ) + return err +} + +func WriteNote(n *Note, userID uuid.UUID) (Note, error) { + db := NewDB() + + var note Note + note.Content = n.Content + note.Title = n.Title + note.Modified = time.Now() + note.Created = time.Now() + + err := db.Pool.QueryRow( + context.Background(), + `INSERT INTO keepnote.notes (title, content, modified, created, user_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + n.Title, n.Content, note.Modified, note.Created, userID, + ).Scan(¬e.ID) + + if err != nil { + return Note{}, err + } + + return note, nil + +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..c86ce78 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,18 @@ +module git.dcntrlized.org/Dcntrlized/KeepNote + +go 1.26.5 + +require ( + git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..3614847 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,32 @@ +git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.0 h1:wlX9v6qSK5UQuLR10ErLG7sRFCHqflXcjlQOCwYbo/E= +git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.0/go.mod h1:1ECTUA+Voaxhajld2XBZL2bHd+GYabFMEcx62CQ52M4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/handlers/actions/action__actionName_.go b/src/handlers/actions/action__actionName_.go new file mode 100644 index 0000000..0133346 --- /dev/null +++ b/src/handlers/actions/action__actionName_.go @@ -0,0 +1,110 @@ +package actions + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/actions" + "git.dcntrlized.org/Dcntrlized/KeepNote/types" +) + +// ActionActionNameGet +func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + actions.Debug(action, "GET", "requested") + + switch action { + case "getNotes": + // Call the GetNotes function from the get.go file + actions.GetNotes(w, r) + case "getNote": + actions.GetNote(w, r) + case "logout": + actions.Logout(w, r, nil) + default: + actions.Warn(action, "GET", "not implemented") + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNamePost +func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + actions.Debug(action, "POST", "decode failed: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + actions.Debug(action, "POST", "requested") + + switch action { + case "writeNote": + // Call the WriteNote function from the post.go file + actions.WriteNote(w, r, req.Payload) + case "login": + actions.Login(w, r, req.Payload) + case "register": + actions.Register(w, r, req.Payload) + case "refresh": + actions.Refresh(w, r, req.Payload) + case "delete": + actions.Delete(w, r, req.Payload) + default: + actions.Warn(action, "POST", "not implemented") + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNamePut +func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + actions.Debug(action, "PUT", "decode failed: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + actions.Debug(action, "PUT", "requested") + + switch action { + case "updateNote": + actions.UpdateNote(w, r, req.Payload) + default: + actions.Warn(action, "PUT", "not implemented") + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNameDelete +func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + actions.Debug(action, "DELETE", "decode failed: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + actions.Debug(action, "DELETE", "requested") + + switch action { + case "deleteNote": + actions.DeleteNote(w, r, req.Payload) + default: + actions.Warn(action, "DELETE", "not implemented") + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} diff --git a/src/handlers/actions/types.go b/src/handlers/actions/types.go new file mode 100644 index 0000000..e3393f2 --- /dev/null +++ b/src/handlers/actions/types.go @@ -0,0 +1,3 @@ +package actions + +// No inline route-specific types were generated for this tag. \ No newline at end of file diff --git a/src/handlers/auth/proxy.go b/src/handlers/auth/proxy.go new file mode 100644 index 0000000..1b6a21a --- /dev/null +++ b/src/handlers/auth/proxy.go @@ -0,0 +1,66 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package auth exposes the service's /auth/* routes as thin reverse proxies +// to the shared dcntrlized-auth service (D6, planning/platform/shared-auth). +// Raw request bodies are forwarded verbatim; responses are relayed unchanged. +package auth + +import ( + "net/http" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +// LoginHandler proxies POST /auth/login {email, password}. +func LoginHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authproxy.Proxy(w, r, "login") + } +} + +// RegisterHandler proxies POST /auth/register {username, password, email}. +func RegisterHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authproxy.Proxy(w, r, "register") + } +} + +// LogoutHandler proxies both POST /auth/logout {refreshToken} (revokes) and +// the legacy GET no-op. +func LogoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + authproxy.ProxyEmpty(w, r, "logout") + return + } + authproxy.Proxy(w, r, "logout") + } +} + +// RefreshHandler proxies POST /auth/refresh {refreshToken}. +func RefreshHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authproxy.Proxy(w, r, "refresh") + } +} + +// DeleteHandler proxies POST /auth/delete {password}; the X-Access-Token +// header is forwarded unchanged by the proxy. +func DeleteHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authproxy.Proxy(w, r, "delete") + } +} diff --git a/src/handlers/manifest/manifest.go b/src/handlers/manifest/manifest.go new file mode 100644 index 0000000..1588375 --- /dev/null +++ b/src/handlers/manifest/manifest.go @@ -0,0 +1,18 @@ +package manifest + +import ( + _ "embed" + "net/http" + + "git.dcntrlized.org/Dcntrlized/KeepNote/logger" +) + +//go:embed manifest.json +var manifest string + +func ManifestHandler(w http.ResponseWriter, r *http.Request) { + logger.Debug("Sending manifest file") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(manifest)) +} diff --git a/src/handlers/manifest/manifest.json b/src/handlers/manifest/manifest.json new file mode 100644 index 0000000..567aaba --- /dev/null +++ b/src/handlers/manifest/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "KeepNote", + "author": "Dcntrlized", + "capabilities": ["database"], + "publicKey": "", + "icon": "" +} diff --git a/src/logger/logger.go b/src/logger/logger.go new file mode 100644 index 0000000..7f6e07e --- /dev/null +++ b/src/logger/logger.go @@ -0,0 +1,145 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "time" +) + +var ( + infoLogger *log.Logger + warnLogger *log.Logger + errorLogger *log.Logger + debugLogger *log.Logger + + once sync.Once +) + +// helperFuncs tracks log entry points and any registered wrapper functions +// (e.g. git.dcntrlized.org/Dcntrlized/KeepNote/actions.Debug) so callerDepth +// can skip them and report the true call site. +var helperFuncs = map[string]bool{} + +func init() { + for _, name := range []string{ + "git.dcntrlized.org/Dcntrlized/KeepNote/logger.Info", + "git.dcntrlized.org/Dcntrlized/KeepNote/logger.Warn", + "git.dcntrlized.org/Dcntrlized/KeepNote/logger.Error", + "git.dcntrlized.org/Dcntrlized/KeepNote/logger.Debug", + } { + helperFuncs[name] = true + } +} + +func initLogger() { + if err := os.MkdirAll("logs", 0755); err != nil { + log.Printf("Failed to create logs directory: %v. Falling back to standard output.", err) + } + + // 1. Setup Info Logger + var infoOutput io.Writer = os.Stdout + if infoFile, err := os.OpenFile("logs/info.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + infoOutput = io.MultiWriter(os.Stdout, infoFile) + writeHeader(infoFile) + } + infoLogger = log.New(infoOutput, "INFO: ", log.Ldate|log.Ltime|log.LUTC) + + // 2. Setup Warn Logger + var warnOutput io.Writer = os.Stdout + if warnFile, err := os.OpenFile("logs/warn.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + warnOutput = io.MultiWriter(os.Stdout, warnFile) + writeHeader(warnFile) + } + warnLogger = log.New(warnOutput, "WARN: ", log.Ldate|log.Ltime|log.LUTC) + + // 3. Setup Error Logger + var errorOutput io.Writer = os.Stderr + if errorFile, err := os.OpenFile("logs/error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + errorOutput = io.MultiWriter(os.Stderr, errorFile) + writeHeader(errorFile) + } + // The error logger does not rely on Lshortfile; Error resolves the true + // call site itself via callSite, so line numbers stay correct even through + // log helper wrappers. + errorLogger = log.New(errorOutput, "ERROR: ", log.Ldate|log.Ltime|log.LUTC) + + // 4. Setup Debug Logger + var debugOutput io.Writer = os.Stdout + if debugFile, err := os.OpenFile("logs/debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + debugOutput = io.MultiWriter(os.Stdout, debugFile) + writeHeader(debugFile) + } + debugLogger = log.New(debugOutput, "DEBUG: ", log.Ldate|log.Ltime|log.LUTC) +} + +func writeHeader(file *os.File) { + file.WriteString("========== Start Log ==========\n") + file.WriteString("[INFO] Logger initialized\n") + file.WriteString("[" + time.Now().Format(time.RFC3339) + "]\n") +} + +// RegisterLogHelper registers a wrapper function around this package's log +// functions so that call sites logged through it point at the true caller +// instead of the wrapper. Pass the fully qualified function name, e.g. +// "git.dcntrlized.org/Dcntrlized/KeepNote/actions.Debug". +func RegisterLogHelper(fullFuncName string) { + helperFuncs[fullFuncName] = true +} + +func isLogHelper(name string) bool { + return helperFuncs[name] +} + +// callSite walks the call stack, skipping this package's entry points and any +// registered log helpers, and returns the file:line of the true call site. +// +//go:noinline +func callSite() (string, int) { + pcs := make([]uintptr, 64) + n := runtime.Callers(2, pcs) + frames := runtime.CallersFrames(pcs[:n]) + for { + frame, more := frames.Next() + if !more { + return "???", 0 + } + if isLogHelper(frame.Function) { + continue + } + return frame.File, frame.Line + } +} + +// Info accepts a format string and an optional, arbitrary number of arguments. +func Info(format string, v ...any) { + once.Do(initLogger) + infoLogger.Printf(format, v...) +} + +// Warn accepts a format string and an optional, arbitrary number of arguments. +func Warn(format string, v ...any) { + once.Do(initLogger) + warnLogger.Printf(format, v...) +} + +// Error accepts a format string and an optional, arbitrary number of arguments. +// The reported file:line points at the true call site, even through registered +// log helper wrappers. +// +//go:noinline +func Error(format string, v ...any) { + once.Do(initLogger) + file, line := callSite() + errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...))) +} + +// Debug accepts a format string and an optional, arbitrary number of arguments. +func Debug(format string, v ...any) { + once.Do(initLogger) + debugLogger.Printf(format, v...) +} diff --git a/src/logger/logs/debug.log b/src/logger/logs/debug.log new file mode 100644 index 0000000..96685ad --- /dev/null +++ b/src/logger/logs/debug.log @@ -0,0 +1,12 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:48+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:54:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:34+01:00] diff --git a/src/logger/logs/error.log b/src/logger/logs/error.log new file mode 100644 index 0000000..cf25bb1 --- /dev/null +++ b/src/logger/logs/error.log @@ -0,0 +1,16 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:10+01:00] +ERROR: 2026/08/09 16:52:10 asm_amd64.s:1771: this should point at logger_test.go:10 +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:48+01:00] +ERROR: 2026/08/09 16:52:48 asm_amd64.s:1771: this should point at logger_test.go:10 +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:54:10+01:00] +ERROR: 2026/08/09 16:54:10 logger.go:135: this should point at logger_test.go:10 +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:34+01:00] +ERROR: 2026/08/09 16:55:34 /home/franciscofadigas/projects/dcntrlized/KeepNote/src/logger/logger_test.go:8: this should point at logger_test.go:10 diff --git a/src/logger/logs/info.log b/src/logger/logs/info.log new file mode 100644 index 0000000..96685ad --- /dev/null +++ b/src/logger/logs/info.log @@ -0,0 +1,12 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:48+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:54:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:34+01:00] diff --git a/src/logger/logs/warn.log b/src/logger/logs/warn.log new file mode 100644 index 0000000..96685ad --- /dev/null +++ b/src/logger/logs/warn.log @@ -0,0 +1,12 @@ +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:52:48+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:54:10+01:00] +========== Start Log ========== +[INFO] Logger initialized +[2026-08-09T17:55:34+01:00] diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..5a9ce74 --- /dev/null +++ b/src/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "net/http" + "strings" + + "git.dcntrlized.org/Dcntrlized/KeepNote/db" + "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/actions" + "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/auth" + "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/manifest" + "git.dcntrlized.org/Dcntrlized/KeepNote/logger" + "git.dcntrlized.org/Dcntrlized/KeepNote/middleware" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +func main() { + + db := db.NewDB() + defer db.Close() + + if err := authclient.Init(context.Background(), authproxy.Target()); err != nil { + logger.Error("failed to initialize auth client: %v", err) + } + + logger.Info("Server started") + mux := http.NewServeMux() + + mux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + manifest.ManifestHandler(w, r) + }) + + mux.HandleFunc("/auth/login", auth.LoginHandler()) + mux.HandleFunc("/auth/register", auth.RegisterHandler()) + mux.HandleFunc("/auth/logout", auth.LogoutHandler()) + mux.HandleFunc("/auth/refresh", auth.RefreshHandler()) + mux.HandleFunc("/auth/delete", auth.DeleteHandler()) + + mux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/action/") { + http.NotFound(w, r) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/action/") + params := map[string]string{"actionName": rest} + switch r.Method { + case http.MethodDelete: + actions.ActionActionNameDelete(w, r, params) + return + case http.MethodGet: + actions.ActionActionNameGet(w, r, params) + return + case http.MethodPost: + actions.ActionActionNamePost(w, r, params) + return + case http.MethodPut: + actions.ActionActionNamePut(w, r, params) + return + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + }) + + handler := middleware.LoggingMiddleware(middleware.CorsMiddleware(mux)) + + http.ListenAndServe(":8080", handler) +} diff --git a/src/middleware/cors.go b/src/middleware/cors.go new file mode 100644 index 0000000..7372c26 --- /dev/null +++ b/src/middleware/cors.go @@ -0,0 +1,26 @@ +package middleware + +import "net/http" + +func CorsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dynamically echo the origin sent by the browser + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + // Note: Match the exact casing the browser requested, just to be safe + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, X-Access-Token") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/src/middleware/logger.go b/src/middleware/logger.go new file mode 100644 index 0000000..c11f15b --- /dev/null +++ b/src/middleware/logger.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "io" + "net" + "net/http" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/KeepNote/logger" +) + +const maxBodyLogBytes = 4096 + +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ip := r.RemoteAddr + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ip = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + + body := readBody(r) + + next.ServeHTTP(w, r) + + logger.Info("%s request=%s %s path=%s query=%q ua=%q ip=%s body=%q duration=%s", + time.Now().Format(time.RFC3339), + r.Method, + r.Proto, + r.URL.Path, + r.URL.RawQuery, + r.UserAgent(), + ip, + body, + time.Since(start), + ) + }) +} + +func readBody(r *http.Request) string { + if !hasBody(r.Method) { + return "" + } + b, err := io.ReadAll(io.LimitReader(r.Body, maxBodyLogBytes)) + if err != nil { + return "" + } + r.Body = io.NopCloser(strings.NewReader(string(b))) + return string(b) +} + +func hasBody(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch: + return true + default: + return false + } +} diff --git a/src/types/types.go b/src/types/types.go new file mode 100644 index 0000000..427843d --- /dev/null +++ b/src/types/types.go @@ -0,0 +1,21 @@ +package types + +import () + +type Manifest struct { + Name string `json:"name"` + Version *string `json:"version,omitempty"` + Author string `json:"author"` + Capabilities []string `json:"capabilities"` + PublicKey string `json:"publicKey"` + Icon *string `json:"icon,omitempty"` +} + +type ActionRequest struct { + Payload interface{} `json:"payload,omitempty"` +} + +type ActionResponse struct { + Status *string `json:"status,omitempty"` + Data interface{} `json:"data,omitempty"` +}