Initial import: KeepNote service (dcntrlized-auth consumer)
This commit is contained in:
+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