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