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