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
+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"`
}