Initial import: KeepNote service (dcntrlized-auth consumer)

This commit is contained in:
2026-08-14 23:35:38 +01:00
commit 77dfcaf64a
49 changed files with 2346 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Go build artifacts (Air runs inside the dev container as root)
src/tmp/
src/src/
src/logs/
+57
View File
@@ -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"]
}
]
}
+36
View File
@@ -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
View File
@@ -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
+65
View File
@@ -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
Executable
+12
View File
@@ -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" .
+76
View File
@@ -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;
+19
View File
@@ -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 $$;
+2
View File
@@ -0,0 +1,2 @@
psycopg2
bcrypt
+11
View File
@@ -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.")
+85
View File
@@ -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()
+75
View File
@@ -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>
+21
View File
@@ -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 "";
}
+23
View File
@@ -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
View File
@@ -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
}
}
+74
View File
@@ -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
View File
@@ -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>
+101
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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)
}
+53
View File
@@ -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
}
+55
View File
@@ -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)
}
+67
View File
@@ -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)
}
+34
View File
@@ -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...))
}
+3
View File
@@ -0,0 +1,3 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-09T17:55:57+01:00]
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-09T17:55:57+01:00]
+5
View File
@@ -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
+52
View File
@@ -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(&note, 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 &note, 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, &note); err != nil {
return db.Note{}, err
}
return note, nil
}
+77
View File
@@ -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
}
+61
View File
@@ -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
View File
@@ -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(&note.ID, &note.Title, &note.Content, &note.Modified, &note.Created, &note.Tags)
if err != nil {
return nil, err
}
return &note, 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(&note.ID, &note.Title, &note.Content, &note.Modified, &note.Created, &note.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(&note.ID, &note.Title, &note.Content, &note.Modified, &note.Created)
if err != nil {
return nil, err
}
return &note, 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(&note.ID)
if err != nil {
return Note{}, err
}
return note, nil
}
+18
View File
@@ -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
View File
@@ -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=
+110
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
package actions
// No inline route-specific types were generated for this tag.
+66
View File
@@ -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")
}
}
+18
View File
@@ -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))
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "KeepNote",
"author": "Dcntrlized",
"capabilities": ["database"],
"publicKey": "",
"icon": ""
}
+145
View File
@@ -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...)
}
+12
View File
@@ -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]
+16
View File
@@ -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
+12
View File
@@ -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]
+12
View File
@@ -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
View File
@@ -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)
}
+26
View File
@@ -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)
})
}
+64
View File
@@ -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
}
}
+21
View File
@@ -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"`
}