Initial import: KeepNote service (dcntrlized-auth consumer)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Go build artifacts (Air runs inside the dev container as root)
|
||||||
|
src/tmp/
|
||||||
|
src/src/
|
||||||
|
src/logs/
|
||||||
Vendored
+57
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+36
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+28
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" .
|
||||||
@@ -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;
|
||||||
@@ -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 $$;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
psycopg2
|
||||||
|
bcrypt
|
||||||
@@ -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 / [email protected] / admin_password_123
|
||||||
|
johndoe / [email protected] / john_password_456
|
||||||
|
janedoe / [email protected] / 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.")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Log In / Register</title>
|
||||||
|
<link rel="stylesheet" href="main.css">
|
||||||
|
<script src="./cookies.js"></script>
|
||||||
|
<script src="./jwt.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>KeepNote</h1>
|
||||||
|
<div id="error"></div>
|
||||||
|
<form id="login">
|
||||||
|
<h2>Log in</h2>
|
||||||
|
<input name="username" type="text" placeholder="username" />
|
||||||
|
<input name="password" type="password" placeholder="password" />
|
||||||
|
<button>Log In</button>
|
||||||
|
</form>
|
||||||
|
<form id="register">
|
||||||
|
<h2>Register</h2>
|
||||||
|
<input name="username" type="text" placeholder="username" />
|
||||||
|
<input name="email" type="text" placeholder="email" />
|
||||||
|
<input name="password" type="password" placeholder="password" />
|
||||||
|
<button>Register</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
const API = "http://127.0.0.1:8081";
|
||||||
|
const errorBox = document.getElementById("error");
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAction(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
showError("");
|
||||||
|
const data = Object.fromEntries(new FormData(event.target).entries());
|
||||||
|
const res = await fetch(`${API}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`login failed: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await res.json();
|
||||||
|
cookieBaker("accessToken", resp.accessToken, new Date(new Date().getTime() + 10 * 60000), "/", null, null, null, null);
|
||||||
|
window.location.replace("./notes.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerAction(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
showError("");
|
||||||
|
const data = Object.fromEntries(new FormData(event.target).entries());
|
||||||
|
const res = await fetch(`${API}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`register failed: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await res.json();
|
||||||
|
cookieBaker("accessToken", resp.accessToken, new Date(new Date().getTime() + 10 * 60000), "/", null, null, null, null);
|
||||||
|
window.location.replace("./notes.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("login").addEventListener("submit", loginAction);
|
||||||
|
document.getElementById("register").addEventListener("submit", registerAction);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Index</title>
|
||||||
|
<script src="./cookies.js"></script>
|
||||||
|
<script src="./jwt.js"></script>
|
||||||
|
<script>
|
||||||
|
const token = cookieEater("accessToken");
|
||||||
|
let authed = false;
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
const res = jwtDecode(token);
|
||||||
|
authed = new Date().valueOf() / 1000 < res.payload.exp;
|
||||||
|
}
|
||||||
|
} catch (e) { /* stale or malformed token */ }
|
||||||
|
window.location.replace(authed ? "./notes.html" : "./auth.html");
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+23
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notes</title>
|
||||||
|
<link rel="stylesheet" href="main.css">
|
||||||
|
<script src="./cookies.js"></script>
|
||||||
|
<script src="./jwt.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>KeepNote</h1>
|
||||||
|
<div id="toolbar">
|
||||||
|
<button id="logout">Log out</button>
|
||||||
|
</div>
|
||||||
|
<form id="create-note">
|
||||||
|
<input name="title" type="text" placeholder="title" required />
|
||||||
|
<input name="content" type="text" placeholder="content" />
|
||||||
|
<button>Add note</button>
|
||||||
|
</form>
|
||||||
|
<div id="error"></div>
|
||||||
|
<ul id="notes"></ul>
|
||||||
|
<script>
|
||||||
|
const API = "http://127.0.0.1:8081";
|
||||||
|
const errorBox = document.getElementById("error");
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return cookieEater("accessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
options = options || {};
|
||||||
|
options.headers = Object.assign({ "X-Access-Token": token() }, options.headers || {});
|
||||||
|
const res = await fetch(`${API}${path}`, options);
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.replace("./auth.html");
|
||||||
|
throw new Error("unauthorized");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNote(note) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
if (editingId === note.id) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "edit-note";
|
||||||
|
const title = document.createElement("input");
|
||||||
|
title.name = "title";
|
||||||
|
title.value = note.title || "";
|
||||||
|
const content = document.createElement("input");
|
||||||
|
content.name = "content";
|
||||||
|
content.value = note.content || "";
|
||||||
|
const tags = document.createElement("input");
|
||||||
|
tags.name = "tags";
|
||||||
|
tags.value = (note.tags || []).join(", ");
|
||||||
|
tags.placeholder = "tags (comma separated)";
|
||||||
|
const save = document.createElement("button");
|
||||||
|
save.textContent = "Save";
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.textContent = "Cancel";
|
||||||
|
cancel.addEventListener("click", () => { editingId = null; loadNotes(); });
|
||||||
|
form.append(title, content, tags, save, cancel);
|
||||||
|
form.addEventListener("submit", (event) => updateNote(event, note.id, title, content, tags));
|
||||||
|
li.appendChild(form);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = note.title || "(untitled)";
|
||||||
|
li.appendChild(title);
|
||||||
|
if (note.content) {
|
||||||
|
li.appendChild(document.createTextNode(" - " + note.content));
|
||||||
|
}
|
||||||
|
for (const tag of note.tags || []) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "tag";
|
||||||
|
badge.textContent = tag;
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
const edit = document.createElement("button");
|
||||||
|
edit.textContent = "Edit";
|
||||||
|
edit.addEventListener("click", () => { editingId = note.id; loadNotes(); });
|
||||||
|
li.appendChild(edit);
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.textContent = "Delete";
|
||||||
|
del.addEventListener("click", () => deleteNote(note.id));
|
||||||
|
li.appendChild(del);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
const res = await api("/action/getNotes");
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`failed to load notes: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const notes = await res.json();
|
||||||
|
const list = document.getElementById("notes");
|
||||||
|
list.innerHTML = "";
|
||||||
|
for (const note of notes) {
|
||||||
|
list.appendChild(renderNote(note));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
showError("");
|
||||||
|
const data = Object.fromEntries(new FormData(event.target).entries());
|
||||||
|
const res = await api("/action/writeNote", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ payload: data })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`failed to add note: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.target.reset();
|
||||||
|
await loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNote(event, id, titleInput, contentInput, tagsInput) {
|
||||||
|
event.preventDefault();
|
||||||
|
showError("");
|
||||||
|
const tags = tagsInput.value.split(",").map((t) => t.trim()).filter((t) => t !== "");
|
||||||
|
const res = await api("/action/updateNote", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ payload: { id: id, title: titleInput.value, content: contentInput.value, tags: tags } })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`failed to update note: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editingId = null;
|
||||||
|
await loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(id) {
|
||||||
|
showError("");
|
||||||
|
const res = await api("/action/deleteNote", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ payload: { id: id } })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`failed to delete note: ${res.status} ${await res.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api("/auth/logout");
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
document.cookie = "accessToken=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/";
|
||||||
|
window.location.replace("./auth.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("create-note").addEventListener("submit", createNote);
|
||||||
|
document.getElementById("logout").addEventListener("click", logout);
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+144
@@ -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
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
========== Start Log ==========
|
||||||
|
[INFO] Logger initialized
|
||||||
|
[2026-08-09T17:55:57+01:00]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
========== Start Log ==========
|
||||||
|
[INFO] Logger initialized
|
||||||
|
[2026-08-09T17:55:57+01:00]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+170
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
+18
@@ -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
|
||||||
|
)
|
||||||
+32
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
// No inline route-specific types were generated for this tag.
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "KeepNote",
|
||||||
|
"author": "Dcntrlized",
|
||||||
|
"capabilities": ["database"],
|
||||||
|
"publicKey": "",
|
||||||
|
"icon": ""
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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]
|
||||||
+73
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user